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