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) { 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); } });