Combine native shipment and ground movement promises
This commit is contained in:
1 parent
13398aa76e
commit
d4e82c74b1
26 files changed
+2341
-10
No files matched your search
@@ -0,0 +1,230 @@
|
||||
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);
|
||||
});
|
||||
Reference in new issue
Block a user