Apply ruled Grumman return and Cunning placement
This commit is contained in:
1 parent
7994026a70
commit
2efa69d10f
24 files changed
+1246
-219
No files matched your search
@@ -0,0 +1,225 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { TERRITORIES, MOBILE_STRONGHOLD } from '../game/board';
|
||||
import {
|
||||
createTerrorState,
|
||||
placeTerror,
|
||||
revealTerror,
|
||||
type TerrorState,
|
||||
} from '../game/moritani-terror';
|
||||
import {
|
||||
createNexusMoritani,
|
||||
validateNexusMoritani,
|
||||
quoteNexusMoritaniPlacement,
|
||||
quoteNexusMoritaniGrummanPlacement,
|
||||
type NexusMoritaniContext,
|
||||
type NexusMoritaniGrummanSource,
|
||||
type NexusMoritaniReceipt,
|
||||
} from '../game/nexus-moritani';
|
||||
|
||||
const context: NexusMoritaniContext = {
|
||||
turn: 3,
|
||||
players: [
|
||||
{ id: 'm', faction: 'moritani' },
|
||||
{ id: 'a', faction: 'atreides' },
|
||||
],
|
||||
};
|
||||
const source: NexusMoritaniGrummanSource = {
|
||||
source: 'grumman',
|
||||
sourceEvent: JSON.stringify(['grummanCollection', 3, 'm']),
|
||||
prerequisite: 'arrakeen',
|
||||
};
|
||||
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value));
|
||||
const fresh = () => createTerrorState(() => 0.3);
|
||||
const eligible = (turn = 1) => {
|
||||
const state = fresh();
|
||||
return placeTerror(state, state.tokens[0].id, 'arrakeen', turn);
|
||||
};
|
||||
const grummanEvent = (receipt: Extract<NexusMoritaniReceipt, { source: 'grumman' }>) =>
|
||||
JSON.stringify([
|
||||
'nexusMoritaniGrumman',
|
||||
receipt.turn,
|
||||
receipt.owner,
|
||||
receipt.token,
|
||||
receipt.territory,
|
||||
receipt.sourceEvent,
|
||||
receipt.prerequisite,
|
||||
]);
|
||||
|
||||
void test('Grumman Cunning receipts bind the native source and ordinary prerequisite to their event and signature', () => {
|
||||
const receipt = createNexusMoritani(context, 'm', 'supply-token', 'polar_sink', source);
|
||||
assert.equal(receipt.source, 'grumman');
|
||||
if (receipt.source !== 'grumman') throw new Error('Expected Grumman receipt');
|
||||
assert.equal(receipt.phase, 7);
|
||||
assert.equal(receipt.event, grummanEvent(receipt));
|
||||
validateNexusMoritani({ ...context, turn: 8 }, clone(receipt));
|
||||
assert.notEqual(
|
||||
receipt.event,
|
||||
createNexusMoritani(context, 'm', 'supply-token', 'polar_sink', {
|
||||
...source,
|
||||
sourceEvent: 'another-native-operation',
|
||||
}).event,
|
||||
);
|
||||
assert.notEqual(
|
||||
receipt.event,
|
||||
createNexusMoritani(context, 'm', 'supply-token', 'polar_sink', {
|
||||
...source,
|
||||
prerequisite: 'carthag',
|
||||
}).event,
|
||||
);
|
||||
const edits: ((r: Extract<NexusMoritaniReceipt, { source: 'grumman' }>) => void)[] = [
|
||||
(r) => { r.sourceEvent = 'other-source'; },
|
||||
(r) => { r.prerequisite = 'carthag'; },
|
||||
(r) => { r.sourceEvent = 'other-source'; r.event = grummanEvent(r); },
|
||||
(r) => { r.prerequisite = 'carthag'; r.event = grummanEvent(r); },
|
||||
(r) => { r.token = 'other-token'; },
|
||||
(r) => { r.territory = 'hagga_basin'; },
|
||||
(r) => { r.turn = 2; },
|
||||
(r) => { r.owner = 'a'; },
|
||||
(r) => { Object.assign(r, { source: 'mentat', phase: 8 }); },
|
||||
(r) => { Object.assign(r, { phase: 8 }); },
|
||||
(r) => { delete (r as Partial<Extract<NexusMoritaniReceipt, { source: 'grumman' }>>).sourceEvent; },
|
||||
(r) => { delete (r as Partial<Extract<NexusMoritaniReceipt, { source: 'grumman' }>>).prerequisite; },
|
||||
(r) => { r.signature = ''; },
|
||||
];
|
||||
for (const edit of edits) {
|
||||
const bad: Extract<NexusMoritaniReceipt, { source: 'grumman' }> = clone(receipt);
|
||||
edit(bad);
|
||||
const before: typeof bad = clone(bad);
|
||||
assert.throws(() => validateNexusMoritani(context, bad));
|
||||
assert.deepEqual(bad, before);
|
||||
}
|
||||
assert.throws(() => validateNexusMoritani({ ...context, turn: 2 }, receipt));
|
||||
for (const prerequisite of ['polar_sink', MOBILE_STRONGHOLD, 'homeworld:moritani', ''])
|
||||
assert.throws(() => createNexusMoritani(context, 'm', 'token', 'polar_sink', {
|
||||
...source,
|
||||
prerequisite,
|
||||
}));
|
||||
assert.throws(() => createNexusMoritani(context, 'm', 'token', 'polar_sink', {
|
||||
...source,
|
||||
sourceEvent: '',
|
||||
}));
|
||||
});
|
||||
|
||||
void test('Existing Mentat evidence authenticates independently of the new Collection source', () => {
|
||||
const legacy: NexusMoritaniReceipt = {
|
||||
version: 1,
|
||||
event: JSON.stringify(['nexusMoritani', 3, 'm', 'token', 'hagga_basin']),
|
||||
source: 'mentat',
|
||||
phase: 8,
|
||||
turn: 3,
|
||||
owner: 'm',
|
||||
token: 'token',
|
||||
territory: 'hagga_basin',
|
||||
roster: [{ id: 'a', faction: 'atreides' }, { id: 'm', faction: 'moritani' }],
|
||||
signature: '',
|
||||
};
|
||||
legacy.signature = JSON.stringify([
|
||||
1, legacy.event, 'mentat', 8, 3, 'm', 'token', 'hagga_basin',
|
||||
[['a', 'atreides'], ['m', 'moritani']],
|
||||
]);
|
||||
validateNexusMoritani(context, legacy);
|
||||
const current = createNexusMoritani(context, 'm', 'token', 'hagga_basin');
|
||||
assert.equal(current.event, legacy.event);
|
||||
assert.equal(current.signature, legacy.signature);
|
||||
validateNexusMoritani(context, current);
|
||||
assert.throws(() => validateNexusMoritani(context, {
|
||||
...legacy,
|
||||
source: 'grumman',
|
||||
phase: 7,
|
||||
sourceEvent: source.sourceEvent,
|
||||
prerequisite: source.prerequisite,
|
||||
}));
|
||||
});
|
||||
|
||||
void test('Grumman Cunning broadens an available ordinary addition to every printed static territory, including empty desert and stacks', () => {
|
||||
const state = eligible();
|
||||
const before = clone(state);
|
||||
const id = state.tokens[1].id;
|
||||
for (const territory of TERRITORIES) {
|
||||
const placed = quoteNexusMoritaniGrummanPlacement(state, id, territory.id, 'arrakeen');
|
||||
assert.equal(placed.tokens[1].status, 'placed');
|
||||
assert.equal(placed.tokens[1].location, territory.id);
|
||||
assert.equal(placed.placementTurn, state.placementTurn);
|
||||
assert.deepEqual(placed.tokens.filter((token) => token.id !== id), state.tokens.filter((token) => token.id !== id));
|
||||
if (territory.id === 'arrakeen')
|
||||
assert.equal(placed.tokens.filter((token) => token.location === 'arrakeen').length, 2);
|
||||
}
|
||||
assert.deepEqual(state, before);
|
||||
const desert = quoteNexusMoritaniGrummanPlacement(state, id, 'hagga_basin', 'arrakeen');
|
||||
const stacked = quoteNexusMoritaniGrummanPlacement(desert, desert.tokens[2].id, 'hagga_basin', 'arrakeen');
|
||||
assert.equal(stacked.tokens.filter((token) => token.location === 'hagga_basin').length, 2);
|
||||
});
|
||||
|
||||
void test('Grumman Cunning requires an actually eligible ordinary stronghold before broadening the destination', () => {
|
||||
const empty = fresh();
|
||||
const desertOnly = quoteNexusMoritaniPlacement(empty, empty.tokens[0].id, 'hagga_basin', 1);
|
||||
const ordinary = eligible();
|
||||
for (const [state, prerequisite] of [
|
||||
[empty, 'arrakeen'],
|
||||
[desertOnly, 'arrakeen'],
|
||||
[desertOnly, 'hagga_basin'],
|
||||
[ordinary, 'carthag'],
|
||||
[ordinary, MOBILE_STRONGHOLD],
|
||||
[ordinary, 'homeworld:moritani'],
|
||||
] as const) {
|
||||
const before = clone(state);
|
||||
assert.throws(() => quoteNexusMoritaniGrummanPlacement(state, state.tokens[1].id, 'polar_sink', prerequisite));
|
||||
assert.deepEqual(state, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Grumman Cunning cannot create supply, reuse revealed tokens or enhance relocation, and rejects excluded destinations', () => {
|
||||
const state = eligible();
|
||||
const revealed = revealTerror(state, state.tokens[0].id);
|
||||
for (const [stock, id] of [
|
||||
[state, 'invented-token'],
|
||||
[state, state.tokens[0].id],
|
||||
[revealed, revealed.tokens[0].id],
|
||||
] as const) {
|
||||
const before = clone(stock);
|
||||
assert.throws(() => quoteNexusMoritaniGrummanPlacement(stock, id, 'polar_sink', 'arrakeen'));
|
||||
assert.deepEqual(stock, before);
|
||||
}
|
||||
for (const territory of [MOBILE_STRONGHOLD, 'homeworld:moritani', 'arrakeen:10', '', '__proto__']) {
|
||||
assert.throws(() => quoteNexusMoritaniGrummanPlacement(state, state.tokens[1].id, territory, 'arrakeen'));
|
||||
assert.throws(() => createNexusMoritani(context, 'm', state.tokens[1].id, territory, source));
|
||||
}
|
||||
const edits: ((stock: TerrorState) => void)[] = [
|
||||
(stock) => { stock.tokens.pop(); },
|
||||
(stock) => { stock.tokens[2].id = stock.tokens[1].id; },
|
||||
(stock) => { stock.tokens[2].kind = stock.tokens[1].kind; },
|
||||
(stock) => { stock.tokens[1].location = 'arrakeen'; },
|
||||
(stock) => { stock.supplyEpoch = -1; },
|
||||
];
|
||||
for (const edit of edits) {
|
||||
const bad = clone(state);
|
||||
edit(bad);
|
||||
const before = clone(bad);
|
||||
assert.throws(() => quoteNexusMoritaniGrummanPlacement(bad, state.tokens[1].id, 'polar_sink', 'arrakeen'));
|
||||
assert.deepEqual(bad, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Grumman Cunning preserves the physical faces, other placements, supply epoch and independent Mentat allowance', () => {
|
||||
let state = eligible();
|
||||
state = quoteNexusMoritaniPlacement(state, state.tokens[1].id, 'hagga_basin', 2);
|
||||
state.supplyEpoch = 4;
|
||||
const before = clone(state);
|
||||
const placed = quoteNexusMoritaniGrummanPlacement(state, state.tokens[2].id, 'polar_sink', 'arrakeen');
|
||||
assert.deepEqual(placed.tokens.map(({ id, kind }) => ({ id, kind })), state.tokens.map(({ id, kind }) => ({ id, kind })));
|
||||
assert.deepEqual(placed.tokens.filter((token) => token.id !== state.tokens[2].id), state.tokens.filter((token) => token.id !== state.tokens[2].id));
|
||||
assert.equal(placed.supplyEpoch, 4);
|
||||
assert.equal(placed.placementTurn, 2);
|
||||
const mentat = quoteNexusMoritaniPlacement(placed, placed.tokens[3].id, 'carthag', 3);
|
||||
assert.equal(mentat.placementTurn, 3);
|
||||
assert.equal(mentat.tokens[2].location, 'polar_sink');
|
||||
placed.tokens[0].location = 'carthag';
|
||||
placed.tokens[4].status = 'removed';
|
||||
assert.deepEqual(state, before);
|
||||
|
||||
const alreadySpent = eligible(3);
|
||||
const collection = quoteNexusMoritaniGrummanPlacement(alreadySpent, alreadySpent.tokens[1].id, 'hagga_basin', 'arrakeen');
|
||||
assert.equal(collection.placementTurn, 3);
|
||||
assert.throws(() => quoteNexusMoritaniPlacement(collection, collection.tokens[2].id, 'carthag', 3));
|
||||
});
|
||||
Reference in new issue
Block a user