Files
Dune/tests/grumman-collection.test.ts

263 lines
8.6 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { newPlayer } from '../game/engine';
import { createHomeworldCustody } from '../game/homeworld-custody';
import { homeworldContext } from '../game/homeworld-game';
import {
quoteGrummanCollection,
quoteGrummanCollectionAction,
type GrummanCollectionAction,
type GrummanCollectionContext,
} from '../game/grumman-collection';
import {
createTerrorState,
placeTerror,
projectTerror,
revealTerror,
TERROR_KINDS,
type TerrorState,
} from '../game/moritani-terror';
function fixture(native = 8, advanced = true): GrummanCollectionContext {
const players = [
newPlayer('m', 'Moritani', 'moritani'),
newPlayer('v', 'Visitor', 'harkonnen'),
];
players[0].reserves = native;
const context = { advanced, players };
const state = placeTerror(
createTerrorState(() => 0),
'terror-1',
'arrakeen',
2,
);
state.supplyEpoch = 4;
return {
...context,
homeworlds: { custody: createHomeworldCustody(homeworldContext(context)) },
moritaniTerror: state,
};
}
const add = {
mode: 'add',
token: 'terror-2',
destination: 'arrakeen',
} as const;
void test('Grumman quotes the exact current high threshold in both rules modes; foreign visitors never qualify', () => {
for (const advanced of [false, true])
for (const native of [0, 7, 8, 20]) {
const g = fixture(native, advanced);
g.players[1].reserves = 0;
g.homeworlds!.custody!.visitors['homeworld:moritani'] = {
v: { normal: 20, elite: 0 },
};
const quote = quoteGrummanCollection(g, 'm');
assert.equal(quote.population, native);
assert.equal(quote.high, native >= 8);
assert.equal(quote.blocked === null, native >= 8);
assert.deepEqual(quote.destinations, native >= 8 ? ['arrakeen'] : []);
assert.equal(quote.tokens.length, native >= 8 ? 5 : 0);
}
const g = fixture();
delete g.homeworlds;
assert.equal(quoteGrummanCollection(g, 'm').population, null);
assert.throws(() => quoteGrummanCollectionAction(g, 'm', add));
assert.throws(() => quoteGrummanCollection(g, 'v'));
});
void test('one available token adds to an existing stack for four bank spice without consuming Mentat or changing identities', () => {
const g = fixture();
const original = structuredClone(g);
const first = quoteGrummanCollectionAction(g, 'm', add);
assert.equal(first.amount, 4);
assert.equal(first.state.placementTurn, 2);
assert.equal(first.state.supplyEpoch, 4);
assert.deepEqual(g, original);
assert.equal(
first.state.tokens.filter((token) => token.location === 'arrakeen').length,
2,
);
assert.deepEqual(
first.state.tokens.map((token) => token.id),
g.moritaniTerror!.tokens.map((token) => token.id),
);
assert.deepEqual(
first.state.tokens.map((token) => token.kind).sort(),
[...TERROR_KINDS].sort(),
);
assert.ok(
first.state.tokens.every(
(token, index) => token !== g.moritaniTerror!.tokens[index],
),
);
// A subsequent independent opportunity can add to an existing stack, while
// ordinary Mentat still requires an empty stronghold and its own turn.
g.moritaniTerror = first.state;
const second = quoteGrummanCollectionAction(g, 'm', {
...add,
token: 'terror-3',
});
assert.equal(
second.state.tokens.filter((token) => token.location === 'arrakeen').length,
3,
);
assert.throws(() => placeTerror(second.state, 'terror-4', 'arrakeen', 3));
assert.equal(
placeTerror(second.state, 'terror-4', 'carthag', 3).placementTurn,
3,
);
assert.deepEqual(JSON.parse(JSON.stringify(second.state)), second.state);
});
void test('offers need available supply and an existing ordinary stronghold token; storm is not a placement input', () => {
const g = fixture();
Object.defineProperty(g, 'storm', {
get() {
throw new Error('Storm is irrelevant to Terror placement');
},
});
assert.equal(quoteGrummanCollectionAction(g, 'm', add).amount, 4);
for (const destination of [
'carthag',
'homeworld:moritani',
'mobile_stronghold',
'arrakeen:10',
'hagga_basin',
])
assert.throws(() =>
quoteGrummanCollectionAction(g, 'm', { ...add, destination }),
);
g.moritaniTerror = createTerrorState(() => 0);
assert.match(quoteGrummanCollection(g, 'm').blocked!, /already containing/);
g.moritaniTerror.tokens.forEach((token) => {
token.status = 'placed';
token.location = 'arrakeen';
});
assert.match(quoteGrummanCollection(g, 'm').blocked!, /no available/);
});
void test('decline gives zero with cloned unchanged custody; removal requires current high population and a placed token', () => {
for (const native of [7, 8]) {
const g = fixture(native);
const before = JSON.stringify(g);
const declined = quoteGrummanCollectionAction(g, 'm', { mode: 'decline' });
assert.equal(declined.amount, 0);
assert.deepEqual(declined.state, g.moritaniTerror);
assert.notEqual(declined.state, g.moritaniTerror);
assert.equal(quoteGrummanCollection(g, 'm').removeBlocked === null, native >= 8);
for (const token of ['terror-2', 'atomics-aftermath', 'absent'])
assert.throws(() => quoteGrummanCollectionAction(g, 'm', { mode: 'remove', token }));
if (native < 8)
assert.throws(() => quoteGrummanCollectionAction(g, 'm', { mode: 'remove', token: 'terror-1' }));
else {
const returned = quoteGrummanCollectionAction(g, 'm', { mode: 'remove', token: 'terror-1' }, () => 0);
assert.equal(returned.amount, 4);
assert.equal(returned.state.tokens.filter(token => token.status === 'available').length, 6);
assert.equal(returned.state.placementTurn, 2);
}
assert.equal(JSON.stringify(g), before);
}
});
void test('adding never relocates, recovers a spent token or accepts stale high eligibility', () => {
const g = fixture();
const offered = quoteGrummanCollection(g, 'm');
offered.tokens[0].status = 'removed';
assert.equal(g.moritaniTerror!.tokens[1].status, 'available');
g.players[0].reserves = 7;
assert.throws(() => quoteGrummanCollectionAction(g, 'm', add));
g.players[0].reserves = 8;
for (const token of ['terror-1', 'missing'])
assert.throws(() =>
quoteGrummanCollectionAction(g, 'm', { ...add, token }),
);
g.moritaniTerror = revealTerror(g.moritaniTerror!, 'terror-1');
assert.throws(() =>
quoteGrummanCollectionAction(g, 'm', { ...add, token: 'terror-1' }),
);
});
void test('malformed physical inventories and operation shapes reject without mutation', () => {
const original = fixture();
const corruptions: ((s: TerrorState) => void)[] = [
(s) => {
s.tokens.pop();
},
(s) => {
s.tokens[1].id = s.tokens[0].id;
},
(s) => {
s.tokens[1].kind = s.tokens[0].kind;
},
(s) => {
s.tokens[1].location = 'carthag';
},
(s) => {
s.tokens[0].location = 'homeworld:moritani';
},
(s) => {
s.tokens[0].status = 'removed';
},
(s) => {
s.tokens.find((t) => t.kind !== 'extortion')!.status = 'extortion';
},
(s) => {
s.supplyEpoch = -1;
},
(s) => {
s.placementTurn = 1.5;
},
];
for (const corrupt of corruptions) {
const g = structuredClone(original);
corrupt(g.moritaniTerror!);
const before = JSON.stringify(g);
assert.throws(() => quoteGrummanCollection(g, 'm'));
assert.throws(() => quoteGrummanCollectionAction(g, 'm', add));
assert.equal(JSON.stringify(g), before);
}
for (const action of [
null,
{},
{ mode: 'add' },
{ ...add, amount: 4 },
{ mode: 'decline', token: 'terror-2' },
])
assert.throws(() =>
quoteGrummanCollectionAction(
original,
'm',
action as GrummanCollectionAction,
),
);
});
void test('the owner quote reads no hands, balances or force positions and stacked public views retain hidden faces', () => {
const g = fixture();
for (const player of g.players)
for (const field of [
'hand',
'spice',
'tanks',
'forces',
'traitors',
'noField',
])
Object.defineProperty(player, field, {
get() {
throw new Error(`Private ${field}`);
},
});
const result = quoteGrummanCollectionAction(g, 'm', add);
const view = projectTerror(result.state, false);
assert.deepEqual(view.tokens, [
{ id: 'terror-1', location: 'arrakeen', status: 'placed' },
{ id: 'terror-2', location: 'arrakeen', status: 'placed' },
]);
for (const token of g.moritaniTerror!.tokens) Object.freeze(token);
Object.freeze(g.moritaniTerror!.tokens);
Object.freeze(g.moritaniTerror);
assert.equal(quoteGrummanCollectionAction(g, 'm', add).amount, 4);
});