231 lines
13 KiB
TypeScript
231 lines
13 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { compileMixedActionExpression, type MixedActionExpression } from '../game/mixed-action-question';
|
|
import {
|
|
finishTravelTurn,
|
|
liveTravelPromises,
|
|
parseTravelActionClaim,
|
|
recordTravelMovement,
|
|
recordTravelShipment,
|
|
travelBranches,
|
|
travelPromiseValue,
|
|
TravelPromiseError,
|
|
validateTravelPromises,
|
|
type TravelActionClaim,
|
|
type TravelBranchLeaf,
|
|
type TravelPromise,
|
|
} from '../game/travel-expression-promises';
|
|
|
|
type Expression = MixedActionExpression<TravelActionClaim>;
|
|
const action = (kind: 'ship' | 'move', territory = 'carthag', minimum = 3): Expression => ({
|
|
kind: 'action', action: { kind, territory, minimum },
|
|
});
|
|
const fact = (): Expression => ({ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 5 } });
|
|
const group = (kind: 'and' | 'or', ...terms: Expression[]): Expression => ({ kind, terms });
|
|
const leaf = (kind: 'ship' | 'move', territory = 'carthag', minimum = 3, answer = true): TravelBranchLeaf =>
|
|
({ kind, territory, minimum, answer });
|
|
function promise(mixed: Expression, answer = true, event = 'travel:1'): TravelPromise {
|
|
return {
|
|
turn: 1, mixed, event, player: 'respondent', asker: 'asker', answer,
|
|
compiled: compileMixedActionExpression(mixed, () => 'yes', answer),
|
|
shipment: { pending: true, event: null }, progress: [], movementClosed: false,
|
|
fulfilled: false, released: false,
|
|
};
|
|
}
|
|
const event = (territory = 'carthag', amount = 3) => ({ territory, amount });
|
|
|
|
void test('travel action leaves use native destination/minimum validation and reject every extra field', () => {
|
|
for (const kind of ['ship', 'move'] as const) {
|
|
const original = { kind, territory: 'carthag', minimum: 3 };
|
|
assert.deepEqual(parseTravelActionClaim(original), original);
|
|
assert.notEqual(parseTravelActionClaim(original), original);
|
|
for (const invalid of [null, [], {}, { ...original, kind: 'revive' },
|
|
{ ...original, minimum: 0 }, { ...original, minimum: 21 }, { ...original, minimum: 1.5 },
|
|
{ ...original, minimum: '3' }, { ...original, territory: 'not-a-territory' },
|
|
{ ...original, turn: 1 }, { ...original, answer: true }, { ...original, maximum: 3 },
|
|
{ ...original, claim: { territory: 'carthag', minimum: 3 } }])
|
|
assert.throws(() => parseTravelActionClaim(invalid), TravelPromiseError);
|
|
}
|
|
});
|
|
|
|
void test('whole Yes AND requires both native events while OR keeps either alternative available', () => {
|
|
const and = promise(group('and', action('ship'), action('move', 'arrakeen', 2)));
|
|
const or = promise(group('or', action('ship'), action('move', 'arrakeen', 2)));
|
|
assert.deepEqual([...travelBranches(and)], [[leaf('ship'), leaf('move', 'arrakeen', 2)]]);
|
|
assert.deepEqual([...travelBranches(or)], [[leaf('ship')], [leaf('move', 'arrakeen', 2)]]);
|
|
const shipped = recordTravelShipment([and, or], 'respondent', 1, event());
|
|
assert.equal(travelPromiseValue(shipped[0]), false);
|
|
assert.equal(shipped[0].fulfilled, false);
|
|
assert.equal(shipped[1].fulfilled, true);
|
|
assert.deepEqual([...travelBranches(shipped[0])], [[leaf('move', 'arrakeen', 2)]]);
|
|
const moved = recordTravelMovement(shipped, 'respondent', 1, event('arrakeen', 2));
|
|
assert.equal(moved[0].fulfilled, true);
|
|
assert.deepEqual([...travelBranches(moved[0])], [[]]);
|
|
assert.equal(liveTravelPromises(moved, 'respondent', 1).length, 0);
|
|
assert.equal(and.shipment.pending, true);
|
|
assert.deepEqual(and.progress, []);
|
|
});
|
|
|
|
void test('whole No applies De Morgan and permits changing branches after a real action', () => {
|
|
const andNo = promise(group('and', action('ship'), action('move', 'arrakeen', 2)), false);
|
|
const orNo = promise(group('or', action('ship'), action('move', 'arrakeen', 2)), false);
|
|
assert.deepEqual([...travelBranches(andNo)], [[leaf('ship', 'carthag', 3, false)],
|
|
[leaf('move', 'arrakeen', 2, false)]]);
|
|
assert.deepEqual([...travelBranches(orNo)], [[leaf('ship', 'carthag', 3, false),
|
|
leaf('move', 'arrakeen', 2, false)]]);
|
|
const shipped = recordTravelShipment([andNo], 'respondent', 1, event());
|
|
assert.equal(shipped[0].fulfilled, false);
|
|
assert.deepEqual([...travelBranches(shipped[0])], [[leaf('move', 'arrakeen', 2, false)]]);
|
|
const before = structuredClone(shipped);
|
|
assert.throws(() => recordTravelMovement(shipped, 'respondent', 1, event('arrakeen', 2)), TravelPromiseError);
|
|
assert.deepEqual(shipped, before);
|
|
assert.throws(() => recordTravelShipment([orNo], 'respondent', 1, event()), TravelPromiseError);
|
|
const skipped = recordTravelShipment([andNo], 'respondent', 1, null);
|
|
assert.ok([...travelBranches(skipped[0])].some(branch => branch.length === 0),
|
|
'Skipping the shipment leaves an unrestricted completion, regardless of redundant narrower DNF branches');
|
|
assert.doesNotThrow(() => recordTravelMovement(skipped, 'respondent', 1, event('arrakeen', 2)));
|
|
});
|
|
|
|
void test('nested branches keep all signed alternatives and remove impossible positive shipment conjunctions', () => {
|
|
const asked = group('and', group('or', action('ship'), action('move')),
|
|
group('or', action('ship', 'arrakeen', 2), action('move', 'arrakeen', 4)));
|
|
const branches = [...travelBranches(promise(asked))];
|
|
assert.deepEqual(branches, [
|
|
[leaf('ship'), leaf('move', 'arrakeen', 4)],
|
|
[leaf('move'), leaf('ship', 'arrakeen', 2)],
|
|
[leaf('move'), leaf('move', 'arrakeen', 4)],
|
|
]);
|
|
assert.deepEqual([...travelBranches(promise(group('and', action('ship'), action('ship', 'arrakeen'))))], []);
|
|
const no = [...travelBranches(promise(asked, false))];
|
|
assert.deepEqual(no, [
|
|
[leaf('ship', 'carthag', 3, false), leaf('move', 'carthag', 3, false)],
|
|
[leaf('ship', 'arrakeen', 2, false), leaf('move', 'arrakeen', 4, false)],
|
|
]);
|
|
});
|
|
|
|
void test('same-destination signed bounds keep strongest minimum or lowest negative cap', () => {
|
|
const asked = group('and', action('move', 'carthag', 2), action('move', 'carthag', 5));
|
|
assert.deepEqual([...travelBranches(promise(asked))], [[leaf('move', 'carthag', 5)]]);
|
|
const negative = promise(group('or', action('move', 'carthag', 2), action('move', 'carthag', 5)), false);
|
|
assert.deepEqual([...travelBranches(negative)], [[leaf('move', 'carthag', 2, false)]]);
|
|
const atCap = recordTravelMovement([negative], 'respondent', 1, event('carthag', 1));
|
|
assert.equal(atCap[0].progress[0].maximum, 1);
|
|
assert.deepEqual([...travelBranches(atCap[0])], [[leaf('move', 'carthag', 2, false)]]);
|
|
assert.throws(() => recordTravelMovement(atCap, 'respondent', 1, event('carthag', 2)), TravelPromiseError);
|
|
});
|
|
|
|
void test('shipment leaves share one event and later answers receive no earlier shipment or movement', () => {
|
|
const expression = group('or', action('ship', 'carthag', 5), action('move', 'arrakeen', 5));
|
|
const old = promise(expression);
|
|
const shipped = recordTravelShipment([old], 'respondent', 1, event('carthag', 4));
|
|
const moved = recordTravelMovement(shipped, 'respondent', 1, event('arrakeen', 4));
|
|
const fresh = promise(expression, true, 'travel:2');
|
|
fresh.shipment.pending = false;
|
|
const otherPlayer = { ...promise(expression, true, 'travel:3'), player: 'other' };
|
|
const otherTurn = { ...promise(expression, true, 'travel:4'), turn: 2 };
|
|
const list = [moved[0], fresh, otherPlayer, otherTurn];
|
|
const next = recordTravelMovement(list, 'respondent', 1, event('arrakeen', 3));
|
|
assert.deepEqual(next[0].progress, [{ territory: 'arrakeen', maximum: 4 }]);
|
|
assert.deepEqual(next[1].progress, [{ territory: 'arrakeen', maximum: 3 }]);
|
|
assert.equal(next[2], otherPlayer);
|
|
assert.equal(next[3], otherTurn);
|
|
const repeatedShipment = recordTravelShipment(next, 'respondent', 1, event('carthag', 20));
|
|
assert.equal(repeatedShipment[0].shipment.event?.amount, 4);
|
|
assert.equal(repeatedShipment[1].shipment.event, null);
|
|
assert.deepEqual([...travelBranches(repeatedShipment[1])], [[leaf('move', 'arrakeen', 5)]]);
|
|
});
|
|
|
|
void test('only the largest physical movement group counts and event updates are immutable', () => {
|
|
const original = promise(action('move', 'carthag', 5));
|
|
const once = recordTravelMovement([original], 'respondent', 1, event('carthag', 3));
|
|
const twice = recordTravelMovement(once, 'respondent', 1, event('carthag', 3));
|
|
assert.equal(travelPromiseValue(twice[0]), false);
|
|
assert.equal(twice[0], once[0]);
|
|
assert.equal(twice[0].progress[0].maximum, 3);
|
|
assert.deepEqual(recordTravelMovement(twice, 'respondent', 1, event('carthag', 0)), twice);
|
|
assert.equal(recordTravelMovement(twice, 'respondent', 1, event('arrakeen', 20))[0], twice[0]);
|
|
const complete = recordTravelMovement(twice, 'respondent', 1, event('carthag', 5));
|
|
assert.equal(complete[0].fulfilled, true);
|
|
assert.deepEqual(original.progress, []);
|
|
assert.equal(once[0].progress[0].maximum, 3);
|
|
});
|
|
|
|
void test('frozen known and unknown facts constrain only the whole selected answer', () => {
|
|
for (const answer of [true, false]) {
|
|
for (const factAnswer of ['yes', 'no', 'unknown'] as const) {
|
|
for (const kind of ['and', 'or'] as const) {
|
|
const mixed = group(kind, fact(), action('move'));
|
|
const record = promise(mixed, answer);
|
|
record.compiled = compileMixedActionExpression(mixed, () => factAnswer, answer);
|
|
assert.deepEqual(record.mixed, mixed);
|
|
if (record.compiled.kind === 'constant') {
|
|
assert.equal(travelPromiseValue(record), record.compiled.value);
|
|
assert.deepEqual([...travelBranches(record)], record.compiled.value === answer ? [[]] : []);
|
|
if (record.compiled.value === answer) {
|
|
record.fulfilled = true;
|
|
validateTravelPromises([record], ['respondent', 'asker']);
|
|
}
|
|
} else {
|
|
assert.deepEqual([...travelBranches(record)], [[leaf('move', 'carthag', 3, answer)]]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
void test('turn closure rejects unmet Yes, fulfills compatible No and folds every closed opportunity', () => {
|
|
const yes = promise(group('or', action('ship'), action('move')));
|
|
const before = structuredClone(yes);
|
|
assert.throws(() => finishTravelTurn([yes], 'respondent', 1), TravelPromiseError);
|
|
assert.deepEqual(yes, before);
|
|
const no = promise(group('and', action('ship'), action('move')), false);
|
|
const closed = finishTravelTurn([no], 'respondent', 1);
|
|
assert.equal(closed[0].fulfilled, true);
|
|
assert.equal(closed[0].movementClosed, true);
|
|
assert.equal(closed[0].shipment.pending, false);
|
|
validateTravelPromises(closed, ['respondent', 'asker']);
|
|
assert.equal(recordTravelMovement(closed, 'respondent', 1, event())[0], closed[0]);
|
|
const projectedClosed = { ...yes, movementClosed: true, shipment: { pending: false, event: null } };
|
|
assert.deepEqual([...travelBranches(projectedClosed)], []);
|
|
assert.deepEqual([...travelBranches({ ...projectedClosed, answer: false })], [[]]);
|
|
const released = { ...yes, released: true };
|
|
const finishedRelease = finishTravelTurn([released], 'respondent', 1)[0];
|
|
assert.equal(finishedRelease.released, true);
|
|
assert.equal(finishedRelease.fulfilled, false);
|
|
assert.deepEqual([...travelBranches(finishedRelease)], [[]]);
|
|
});
|
|
|
|
void test('stored structure, whole-answer progress and strict native events reject malformed input', () => {
|
|
const original = promise(group('and', action('ship'), action('move')));
|
|
validateTravelPromises([original], ['respondent', 'asker']);
|
|
const corruptions: unknown[] = [
|
|
{ ...original, turn: 0 }, { ...original, answer: 'yes' }, { ...original, fulfilled: undefined },
|
|
{ ...original, fulfilled: true, released: true },
|
|
{ ...original, shipment: { pending: true, event: event() } },
|
|
{ ...original, shipment: { pending: false, event: { ...event(), elite: 1 } } },
|
|
{ ...original, progress: [{ territory: 'carthag', maximum: 21 }] },
|
|
{ ...original, progress: [{ territory: 'arrakeen', maximum: 3 }] },
|
|
{ ...original, progress: [{ territory: 'carthag', maximum: 1 }, { territory: 'carthag', maximum: 2 }] },
|
|
{ ...original, compiled: { kind: 'constant', value: 'unknown' } },
|
|
{ ...original, compiled: { kind: 'action', action: { kind: 'move', territory: 'arrakeen', minimum: 3 } } },
|
|
{ ...original, compiled: { kind: 'and', terms: [] } },
|
|
{ ...original, mixed: { kind: 'constant', value: true } },
|
|
{ ...original, mixed: fact() },
|
|
];
|
|
for (const corrupted of corruptions)
|
|
assert.throws(() => travelPromiseValue(corrupted as TravelPromise));
|
|
for (const record of [{ ...original, event: '' }, { ...original, asker: 'respondent' },
|
|
{ ...original, player: 'missing' }, { ...original, fulfilled: true }])
|
|
assert.throws(() => validateTravelPromises([record], ['respondent', 'asker']), TravelPromiseError);
|
|
assert.throws(() => validateTravelPromises([original, original], ['respondent', 'asker']), TravelPromiseError);
|
|
for (const invalid of [{ ...event(), amount: 21 }, { ...event(), amount: -1 },
|
|
{ ...event(), amount: 2.5 }, { ...event(), amount: '3' },
|
|
{ ...event(), territory: 'missing' }, { ...event(), elite: 1 }]) {
|
|
assert.throws(() => recordTravelMovement([original], 'respondent', 1, invalid as never), TravelPromiseError);
|
|
assert.throws(() => recordTravelShipment([original], 'respondent', 1, invalid as never), TravelPromiseError);
|
|
}
|
|
const violatedNo = { ...original, answer: false,
|
|
shipment: { pending: false, event: event() }, progress: [{ territory: 'carthag', maximum: 3 }] };
|
|
assert.throws(() => validateTravelPromises([violatedNo], ['respondent', 'asker']), TravelPromiseError);
|
|
});
|