223 lines
7.1 KiB
TypeScript
223 lines
7.1 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
normalizeAutomaticGame,
|
|
viewGame,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import { quotePlacementCancellation } from '../game/placement-cancellation';
|
|
import { placeTerror } from '../game/moritani-terror';
|
|
import {
|
|
grummanCollectionFixture,
|
|
enterGrummanCollection,
|
|
addGrummanToken,
|
|
holdGrummanCard,
|
|
grummanToken,
|
|
grummanPlayer,
|
|
grummanReload,
|
|
grummanInventory,
|
|
} from './fixture-grumman-collection';
|
|
|
|
function pendingPlacement(advanced = false, low = false) {
|
|
const initial = grummanCollectionFixture({ advanced });
|
|
const karama = holdGrummanCard(initial, 'a', 'karama');
|
|
let g = addGrummanToken(enterGrummanCollection(initial));
|
|
if (low) {
|
|
// Conserved later position: the existing legal stack outlives its creating
|
|
// high-side population. Actual phase actions record this physical change.
|
|
grummanPlayer(g, 'm').reserves--;
|
|
grummanPlayer(g, 'm').forces['polar_sink:0']++;
|
|
}
|
|
for (const player of g.players)
|
|
g = applyAction(g, player.id, { type: 'ready' });
|
|
assert.equal(g.phase, 8);
|
|
assert.equal(g.decision?.kind, 'moritaniPlacement');
|
|
g = applyAction(g, 'm', {
|
|
type: 'decision',
|
|
token: grummanToken(g, 'assassination').id,
|
|
territory: 'carthag',
|
|
});
|
|
assert.equal(g.response?.kind, 'moritaniPlacement');
|
|
assert.equal(
|
|
g.moritaniTerror!.tokens.filter((token) => token.location === 'arrakeen')
|
|
.length,
|
|
2,
|
|
);
|
|
return { g, action: { type: 'card', card: karama.id, mode: 'cancel' } };
|
|
}
|
|
|
|
void test('real Basic and Advanced Grumman stacks survive printed Karama canceling the separate Mentat placement', () => {
|
|
for (const advanced of [false, true]) {
|
|
const { g, action } = pendingPlacement(advanced);
|
|
const before = grummanReload(g);
|
|
const result = applyAction(grummanReload(g), 'a', action);
|
|
assert.deepEqual(g, before);
|
|
assert.deepEqual(result.moritaniTerror!.tokens, g.moritaniTerror!.tokens);
|
|
assert.equal(result.moritaniTerror!.placementTurn, g.turn);
|
|
assert.equal(result.pendingMoritaniPlacement, null);
|
|
assert.equal(
|
|
result.discard.filter((card) => card.id === action.card).length,
|
|
1,
|
|
);
|
|
assert.equal(grummanPlayer(result, 'm').spice, 24);
|
|
assert.equal(grummanToken(result, 'assassination').status, 'available');
|
|
assert.throws(
|
|
() =>
|
|
placeTerror(
|
|
result.moritaniTerror!,
|
|
grummanToken(result, 'assassination').id,
|
|
'carthag',
|
|
g.turn,
|
|
),
|
|
/already been used/,
|
|
);
|
|
assert.throws(() => applyAction(result, 'a', action));
|
|
assert.deepEqual(normalizeAutomaticGame(grummanReload(result)), result);
|
|
for (const id of ['a', 'g']) {
|
|
const projected = viewGame(result, id);
|
|
assert.ok(
|
|
projected.moritaniTerror!.tokens.every((token) => !('kind' in token)),
|
|
);
|
|
assert.equal(
|
|
projected.players.find((player) => player.id === 'm')!.hand,
|
|
undefined,
|
|
);
|
|
}
|
|
grummanInventory(result);
|
|
}
|
|
});
|
|
|
|
void test('placement denial preserves an earlier stack at low population and the pure quote accepts a later turn without private hands', () => {
|
|
const { g, action } = pendingPlacement(true, true);
|
|
assert.equal(grummanPlayer(g, 'm').reserves, 7);
|
|
const result = applyAction(grummanReload(g), 'a', action);
|
|
assert.deepEqual(result.moritaniTerror!.tokens, g.moritaniTerror!.tokens);
|
|
grummanInventory(result);
|
|
// This is a pure future-turn eligibility probe, not a fabricated live phase
|
|
// transition. Existing placement history remains unchanged.
|
|
const later = grummanReload(g);
|
|
later.turn++;
|
|
later.pendingMoritaniPlacement!.turn = later.turn;
|
|
for (const player of later.players)
|
|
for (const field of ['hand', 'spice', 'traitors'])
|
|
Object.defineProperty(player, field, {
|
|
get() {
|
|
throw new Error('Private field read: ' + field);
|
|
},
|
|
});
|
|
const quote = quotePlacementCancellation(later, later.response!);
|
|
assert.equal(quote?.kind, 'moritaniPlacement');
|
|
if (quote?.kind !== 'moritaniPlacement')
|
|
assert.fail('Missing placement denial');
|
|
assert.deepEqual(quote.terror.tokens, g.moritaniTerror!.tokens);
|
|
assert.equal(quote.terror.placementTurn, later.turn);
|
|
});
|
|
|
|
void test('denial tolerates changed placement feasibility but rejects malformed custody and source records without cost', () => {
|
|
const { g, action } = pendingPlacement();
|
|
const changedPlacements: [string, (bad: Game) => void][] = [
|
|
[
|
|
'removed source',
|
|
(bad) => {
|
|
grummanToken(bad, 'assassination').status = 'removed';
|
|
},
|
|
],
|
|
[
|
|
'same source and target',
|
|
(bad) => {
|
|
bad.pendingMoritaniPlacement!.token = grummanToken(bad, 'robbery').id;
|
|
bad.pendingMoritaniPlacement!.territory = 'arrakeen';
|
|
},
|
|
],
|
|
[
|
|
'occupied destination',
|
|
(bad) => {
|
|
bad.pendingMoritaniPlacement!.territory = 'arrakeen';
|
|
},
|
|
],
|
|
];
|
|
// Denying a declared placement does not attempt it again. Changed source
|
|
// availability or an occupied target must not turn cancellation into a move.
|
|
for (const [label, edit] of changedPlacements) {
|
|
const changed = grummanReload(g);
|
|
edit(changed);
|
|
const before = grummanReload(changed);
|
|
const quoted = quotePlacementCancellation(changed, changed.response!);
|
|
assert.equal(quoted?.kind, 'moritaniPlacement', label);
|
|
const canceled = applyAction(changed, 'a', action);
|
|
assert.deepEqual(
|
|
canceled.moritaniTerror!.tokens,
|
|
changed.moritaniTerror!.tokens,
|
|
label,
|
|
);
|
|
assert.equal(canceled.moritaniTerror!.placementTurn, changed.turn, label);
|
|
assert.deepEqual(changed, before, label);
|
|
grummanInventory(canceled);
|
|
}
|
|
const edits: [string, (bad: Game) => void][] = [
|
|
[
|
|
'non-stronghold target',
|
|
(bad) => {
|
|
bad.pendingMoritaniPlacement!.territory = 'polar_sink';
|
|
},
|
|
],
|
|
[
|
|
'missing token',
|
|
(bad) => {
|
|
bad.pendingMoritaniPlacement!.token = 'not-a-token';
|
|
},
|
|
],
|
|
[
|
|
'duplicate physical ID',
|
|
(bad) => {
|
|
bad.moritaniTerror!.tokens[0].id = bad.moritaniTerror!.tokens[1].id;
|
|
},
|
|
],
|
|
[
|
|
'duplicate physical face',
|
|
(bad) => {
|
|
bad.moritaniTerror!.tokens[0].kind = bad.moritaniTerror!.tokens[1].kind;
|
|
},
|
|
],
|
|
[
|
|
'malformed status',
|
|
(bad) => {
|
|
Object.assign(grummanToken(bad, 'assassination'), {
|
|
status: 'invented',
|
|
});
|
|
},
|
|
],
|
|
[
|
|
'stale declaration',
|
|
(bad) => {
|
|
bad.pendingMoritaniPlacement!.turn--;
|
|
},
|
|
],
|
|
[
|
|
'wrong faction owner',
|
|
(bad) => {
|
|
bad.response!.owner = 'a';
|
|
},
|
|
],
|
|
[
|
|
'duplicate response owner',
|
|
(bad) => {
|
|
bad.players.push(structuredClone(grummanPlayer(bad, 'm')));
|
|
},
|
|
],
|
|
];
|
|
for (const [label, edit] of edits) {
|
|
const bad = grummanReload(g);
|
|
edit(bad);
|
|
const before = grummanReload(bad);
|
|
assert.throws(
|
|
() => quotePlacementCancellation(bad, bad.response!),
|
|
/./,
|
|
label,
|
|
);
|
|
assert.throws(() => applyAction(bad, 'a', action), /./, label);
|
|
assert.deepEqual(bad, before, label);
|
|
}
|
|
});
|