334 lines
12 KiB
TypeScript
334 lines
12 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
normalizeAutomaticGame,
|
|
viewGame,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { grummanCollectionActions } from '../game/grumman-collection-options';
|
|
import {
|
|
grummanCollectionFixture,
|
|
enterGrummanCollection,
|
|
grummanPlayer,
|
|
grummanReload,
|
|
grummanToken,
|
|
addGrummanToken,
|
|
holdGrummanCard,
|
|
stageGrummanArrival,
|
|
enterGrummanStack,
|
|
grummanReject,
|
|
grummanInventory,
|
|
} from './fixture-grumman-collection';
|
|
|
|
function stable(g: Game) {
|
|
grummanInventory(g);
|
|
assert.deepEqual(normalizeAutomaticGame(grummanReload(g)), g);
|
|
for (const p of g.players) {
|
|
const view = viewGame(grummanReload(g), p.id);
|
|
for (const other of view.players.filter((seat) => seat.id !== p.id)) {
|
|
assert.equal(other.hand, undefined);
|
|
assert.equal(other.spice, undefined);
|
|
}
|
|
}
|
|
}
|
|
function mentat(g: Game) {
|
|
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
|
assert.equal(g.phase, 8);
|
|
assert.equal(g.decision?.kind, 'moritaniPlacement');
|
|
return g;
|
|
}
|
|
function decide(g: Game, extra: Record<string, unknown>) {
|
|
return applyAction(grummanReload(g), 'm', { type: 'decision', ...extra });
|
|
}
|
|
|
|
void test('actual Basic and Advanced Collection opens at native eight, adds exactly one hidden token and pays four once', () => {
|
|
for (const advanced of [false, true]) {
|
|
const initial = grummanCollectionFixture({ advanced });
|
|
const tokens = structuredClone(initial.moritaniTerror);
|
|
let g = enterGrummanCollection(initial);
|
|
assert.equal(g.grummanCollection?.stage, 'choice');
|
|
assert.equal(grummanPlayer(g, 'm').spice, 20);
|
|
assert.deepEqual(g.moritaniTerror, tokens);
|
|
const event = g.grummanCollection!.event;
|
|
const token = grummanToken(g, 'sabotage').id;
|
|
stable(g);
|
|
g = addGrummanToken(grummanReload(g));
|
|
assert.equal(grummanPlayer(g, 'm').spice, 24);
|
|
assert.equal(g.grummanCollection?.outcome, 'add');
|
|
assert.equal(g.grummanCollection?.stage, 'complete');
|
|
assert.equal(g.moritaniTerror?.placementTurn, 1);
|
|
assert.deepEqual(
|
|
g.moritaniTerror?.tokens.filter((t) => t.id !== token),
|
|
tokens!.tokens.filter((t) => t.id !== token),
|
|
);
|
|
assert.deepEqual(grummanToken(g, 'sabotage'), {
|
|
id: token,
|
|
kind: 'sabotage',
|
|
status: 'placed',
|
|
location: 'arrakeen',
|
|
});
|
|
assert.equal(
|
|
g.moritaniTerror?.tokens.filter((t) => t.location === 'arrakeen').length,
|
|
2,
|
|
);
|
|
grummanReject(g, 'm', {
|
|
type: 'decision',
|
|
event,
|
|
mode: 'add',
|
|
token,
|
|
territory: 'arrakeen',
|
|
});
|
|
stable(g);
|
|
}
|
|
});
|
|
|
|
void test('low seven waits without income and expires on Mentat; a current high choice may decline after falling low', () => {
|
|
const low = enterGrummanCollection(grummanCollectionFixture({ native: 7 }));
|
|
assert.equal(low.grummanCollection?.stage, 'waiting');
|
|
assert.equal(low.decision, null);
|
|
assert.equal(viewGame(low, 'm').grummanCollection, null);
|
|
stable(low);
|
|
const expired = mentat(low);
|
|
assert.equal(expired.grummanCollection?.outcome, 'expired');
|
|
assert.equal(grummanPlayer(expired, 'm').spice, 20);
|
|
assert.deepEqual(expired.moritaniTerror, low.moritaniTerror);
|
|
stable(expired);
|
|
|
|
const high = enterGrummanCollection(grummanCollectionFixture());
|
|
// Conserved later population change tests use-time eligibility without
|
|
// fabricating a second Collection event or its payment.
|
|
grummanPlayer(high, 'm').reserves--;
|
|
grummanPlayer(high, 'm').forces['polar_sink:0']++;
|
|
const event = high.grummanCollection!.event;
|
|
assert.match(viewGame(high, 'm').grummanCollection!.blocked!, /eight native/);
|
|
grummanReject(
|
|
high,
|
|
'm',
|
|
{
|
|
type: 'decision',
|
|
event,
|
|
mode: 'add',
|
|
token: grummanToken(high, 'sabotage').id,
|
|
territory: 'arrakeen',
|
|
},
|
|
/eight native/,
|
|
);
|
|
const declined = decide(high, { event, decline: true });
|
|
assert.equal(declined.grummanCollection?.outcome, 'decline');
|
|
assert.equal(grummanPlayer(declined, 'm').spice, 20);
|
|
assert.deepEqual(declined.moritaniTerror, high.moritaniTerror);
|
|
stable(declined);
|
|
});
|
|
|
|
void test('owner event, physical availability and destination reject before token or spice mutation', () => {
|
|
const g = enterGrummanCollection(grummanCollectionFixture());
|
|
const event = g.grummanCollection!.event;
|
|
const token = grummanToken(g, 'sabotage').id;
|
|
for (const action of [
|
|
{ event: 'old-event', mode: 'add', token, territory: 'arrakeen' },
|
|
{
|
|
event,
|
|
mode: 'add',
|
|
token: grummanToken(g, 'robbery').id,
|
|
territory: 'arrakeen',
|
|
},
|
|
{ event, mode: 'add', token, territory: 'carthag' },
|
|
{ event, decline: true, token },
|
|
])
|
|
grummanReject(g, 'm', { type: 'decision', ...action });
|
|
grummanReject(g, 'a', {
|
|
type: 'decision',
|
|
event,
|
|
mode: 'add',
|
|
token,
|
|
territory: 'arrakeen',
|
|
});
|
|
for (const id of ['a', 'g']) {
|
|
const view = viewGame(g, id);
|
|
assert.deepEqual(view.grummanCollection!.tokens, []);
|
|
assert.deepEqual(view.grummanCollection!.destinations, []);
|
|
assert.deepEqual(grummanCollectionActions(view), []);
|
|
assert.ok(view.moritaniTerror!.tokens.every((t) => !('kind' in t)));
|
|
}
|
|
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
|
const view = viewGame(g, 'm');
|
|
view.players.find((p) => p.id === 'm')!.bot = profile;
|
|
assert.deepEqual(botActions(view), grummanCollectionActions(view));
|
|
const played = applyAction(grummanReload(g), 'm', botActions(view)[0]);
|
|
assert.equal(grummanPlayer(played, 'm').spice, 24);
|
|
stable(played);
|
|
}
|
|
});
|
|
|
|
void test('Collection addition preserves the separate actual Mentat placement and its Karama response', () => {
|
|
const initial = grummanCollectionFixture();
|
|
holdGrummanCard(initial, 'a', 'karama');
|
|
let g = mentat(addGrummanToken(enterGrummanCollection(initial)));
|
|
const token = grummanToken(g, 'assassination').id;
|
|
g = decide(g, { token, territory: 'carthag' });
|
|
assert.equal(g.response?.kind, 'moritaniPlacement');
|
|
assert.equal(grummanToken(g, 'assassination').status, 'available');
|
|
for (let n = 0; g.response && n < 8; n++) {
|
|
const responder = g.players.find(
|
|
(p) => !g.response!.passed.includes(p.id),
|
|
)!;
|
|
g = applyAction(g, responder.id, { type: 'passResponse' });
|
|
}
|
|
assert.equal(g.response, null);
|
|
assert.equal(grummanToken(g, 'assassination').location, 'carthag');
|
|
assert.equal(g.moritaniTerror!.placementTurn, 2);
|
|
assert.equal(grummanPlayer(g, 'm').spice, 24);
|
|
assert.equal(g.grummanCollection?.outcome, 'add');
|
|
stable(g);
|
|
});
|
|
|
|
void test('actual stacked entry privately selects either Robbery or Sabotage and consumes only the selected token', () => {
|
|
for (const kind of ['robbery', 'sabotage'] as const) {
|
|
const initial = grummanCollectionFixture();
|
|
const shield = holdGrummanCard(initial, 'a', 'shield');
|
|
holdGrummanCard(initial, 'm', 'worthless');
|
|
const collection = addGrummanToken(enterGrummanCollection(initial));
|
|
let g = enterGrummanStack(stageGrummanArrival(collection));
|
|
assert.equal(g.pendingTerrorEntry?.stage, 'select');
|
|
assert.equal(g.pendingTerrorEntry?.amount, 1);
|
|
assert.equal(grummanPlayer(g, 'a').reserves, 19);
|
|
assert.equal(grummanPlayer(g, 'a').spice, 19);
|
|
const owned = viewGame(g, 'm').terrorEntry!;
|
|
assert.equal(owned.kind, undefined);
|
|
assert.equal(owned.candidates?.length, 2);
|
|
for (const id of ['a', 'g']) {
|
|
assert.equal(viewGame(g, id).terrorEntry?.candidates, undefined);
|
|
assert.equal(viewGame(g, id).terrorEntry?.kind, undefined);
|
|
}
|
|
stable(g);
|
|
grummanReject(g, 'm', { type: 'decision', reveal: true }, /Choose one/);
|
|
grummanReject(
|
|
g,
|
|
'm',
|
|
{ type: 'decision', token: grummanToken(g, 'atomics').id },
|
|
/original stacked/,
|
|
);
|
|
const allHidden = decide(g, { decline: true });
|
|
assert.equal(allHidden.pendingTerrorEntry, null);
|
|
assert.deepEqual(allHidden.moritaniTerror, g.moritaniTerror);
|
|
const token = grummanToken(g, kind).id;
|
|
const others = g.moritaniTerror!.tokens.filter((t) => t.id !== token);
|
|
g = decide(g, { token });
|
|
assert.equal(g.pendingTerrorEntry?.stage, 'offer');
|
|
assert.equal(viewGame(g, 'm').terrorEntry?.kind, kind);
|
|
stable(g);
|
|
g = decide(g, { reveal: true });
|
|
if (kind === 'robbery') g = decide(g, { choice: 'spice' });
|
|
else {
|
|
assert.equal(g.pendingTerrorEntry?.stage, 'gift');
|
|
g = decide(g, { decline: true });
|
|
}
|
|
assert.equal(g.pendingTerrorEntry, null);
|
|
assert.equal(grummanToken(g, kind).status, 'removed');
|
|
assert.deepEqual(
|
|
g.moritaniTerror!.tokens.filter((t) => t.id !== token),
|
|
others,
|
|
);
|
|
assert.equal(grummanPlayer(g, 'a').forces['arrakeen:10'], 1);
|
|
assert.equal(grummanPlayer(g, 'a').reserves, 19);
|
|
assert.equal(grummanPlayer(g, 'm').spice, kind === 'robbery' ? 34 : 24);
|
|
assert.equal(
|
|
g.discard.filter((card) => card.id === shield.id).length,
|
|
kind === 'sabotage' ? 1 : 0,
|
|
);
|
|
grummanReject(g, 'm', { type: 'decision', reveal: true });
|
|
stable(g);
|
|
}
|
|
});
|
|
|
|
void test('Karama cancellation restores the selected stacked alliance offer without changing the other hidden token', () => {
|
|
const initial = grummanCollectionFixture();
|
|
const card = holdGrummanCard(initial, 'a', 'karama');
|
|
let g = enterGrummanStack(
|
|
stageGrummanArrival(addGrummanToken(enterGrummanCollection(initial))),
|
|
);
|
|
const token = grummanToken(g, 'robbery').id;
|
|
g = decide(g, { token });
|
|
const others = structuredClone(g.moritaniTerror);
|
|
g = decide(g, { alliance: true });
|
|
assert.equal(g.response?.kind, 'moritaniAlliance');
|
|
g = applyAction(grummanReload(g), 'a', {
|
|
type: 'card',
|
|
card: card.id,
|
|
mode: 'cancel',
|
|
});
|
|
assert.equal(g.response, null);
|
|
assert.equal(g.pendingTerrorEntry?.token, token);
|
|
assert.equal(g.pendingTerrorEntry?.stage, 'offer');
|
|
assert.equal(viewGame(g, 'm').terrorEntry?.canOfferAlliance, false);
|
|
assert.deepEqual(g.moritaniTerror, others);
|
|
assert.equal(g.discard.filter((c) => c.id === card.id).length, 1);
|
|
stable(g);
|
|
g = decide(g, { reveal: true });
|
|
g = decide(g, { choice: 'spice' });
|
|
assert.equal(grummanToken(g, 'sabotage').status, 'placed');
|
|
assert.equal(grummanToken(g, 'robbery').status, 'removed');
|
|
stable(g);
|
|
});
|
|
|
|
void test('signed collection and selected stack reject corrupted phase, completion, candidate or selection on reads and actions', () => {
|
|
const collection = enterGrummanCollection(grummanCollectionFixture());
|
|
const entry = decide(
|
|
enterGrummanStack(stageGrummanArrival(addGrummanToken(collection))),
|
|
{ token: grummanToken(collection, 'sabotage').id },
|
|
);
|
|
for (const [source, corrupt] of [
|
|
[
|
|
collection,
|
|
(g: Game) => {
|
|
g.grummanCollection!.stage = 'complete';
|
|
},
|
|
],
|
|
[
|
|
collection,
|
|
(g: Game) => {
|
|
g.grummanCollection!.turn++;
|
|
},
|
|
],
|
|
[
|
|
collection,
|
|
(g: Game) => {
|
|
g.decision = null;
|
|
},
|
|
],
|
|
[
|
|
entry,
|
|
(g: Game) => {
|
|
g.pendingTerrorEntry!.token = grummanToken(g, 'robbery').id;
|
|
},
|
|
],
|
|
[
|
|
entry,
|
|
(g: Game) => {
|
|
g.pendingTerrorEntry!.candidates!.pop();
|
|
},
|
|
],
|
|
] as const) {
|
|
const g = grummanReload(source);
|
|
corrupt(g);
|
|
const before = grummanReload(g);
|
|
for (const p of g.players)
|
|
assert.throws(
|
|
() => viewGame(g, p.id),
|
|
/Grumman|Terror|original|selection/i,
|
|
);
|
|
assert.throws(
|
|
() => normalizeAutomaticGame(g),
|
|
/Grumman|Terror|original|selection/i,
|
|
);
|
|
grummanReject(g, 'm', {
|
|
type: 'decision',
|
|
event: g.grummanCollection?.event,
|
|
decline: true,
|
|
});
|
|
assert.deepEqual(g, before);
|
|
}
|
|
});
|