import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, viewGame, type Action, type Game } from '../game/engine'; import { quoteGrummanCollection, quoteGrummanCollectionAction } from '../game/grumman-collection'; import { grummanCollectionSignature, validateGrummanCollection } from '../game/grumman-collection-return'; import { placeTerror, projectTerror, revealTerror, TERROR_KINDS } from '../game/moritani-terror'; import { nextNativeE3HomeworldStep } from './fixture-homeworld-native-e3-modules'; import { assertRuledGrummanInventory, createRuledGrummanFixture } from './fixture-ruled-grumman'; function reject(game: Game, actor: string, action: Action): void { const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before); } void test('native Grumman removal returns the unrevealed face to rotated supply, credits four spice once and preserves Mentat use', () => { for (const advanced of [false, true]) { const f = createRuledGrummanFixture({ advanced }); const original = structuredClone(f.game); const prior = f.game.moritaniTerror!; const face = prior.tokens.find(t => t.id === f.placed)!.kind; const owner = f.game.players.find(p => p.id === f.owner)!; const request: Action = { type: 'decision', event: f.game.grummanCollection!.event, mode: 'remove', token: f.placed }; reject(f.game, 'guild', request); for (const token of [prior.tokens.find(t => t.status === 'available')!.id, 'missing']) reject(f.game, f.owner, { ...request, token }); let game = applyAction(f.game, f.owner, request); assert.deepEqual(f.game, original); assert.equal(game.players.find(p => p.id === f.owner)!.spice, owner.spice + 4); assert.equal(game.grummanCollection!.stage, 'complete'); assert.equal(game.grummanCollection!.outcome, 'remove'); assert.equal(game.grummanCollection!.token, f.placed); assert.equal(game.grummanCollection!.territory, f.placedTerritory); assert.equal(game.moritaniTerror!.placementTurn, prior.placementTurn); assert.equal(game.moritaniTerror!.supplyEpoch, (prior.supplyEpoch ?? 0) + 1); assert.deepEqual(game.moritaniTerror!.tokens.map(t => t.kind).sort(), [...TERROR_KINDS].sort()); assert.ok(game.moritaniTerror!.tokens.every(t => t.status === 'available' && t.location === null)); assert.ok(game.moritaniTerror!.tokens.every(t => !prior.tokens.some(old => old.id === t.id))); assert.deepEqual(projectTerror(game.moritaniTerror!, false).tokens, []); assertRuledGrummanInventory(game); validateGrummanCollection(game); reject(game, f.owner, request); reject(game, f.owner, { ...request, mode: 'add', token: game.moritaniTerror!.tokens[0].id, territory: f.placedTerritory }); for (let count = 0; game.decision?.kind !== 'moritaniPlacement' && count < 100; count++) { const step = nextNativeE3HomeworldStep(game); assert.ok(step); game = applyAction(game, step.actor, step.action); } assert.equal(game.decision?.kind, 'moritaniPlacement'); const returned = game.moritaniTerror!.tokens.find(t => t.kind === face)!; assert.notEqual(returned.id, f.placed); game = applyAction(game, f.owner, { type: 'decision', token: returned.id, territory: 'carthag' }); for (let count = 0; game.response && count < 20; count++) { const step = nextNativeE3HomeworldStep(game); assert.ok(step); game = applyAction(game, step.actor, step.action); } assert.equal(game.moritaniTerror!.placementTurn, 2); const redeployed = projectTerror(game.moritaniTerror!, false).tokens.find(t => t.id === returned.id)!; assert.deepEqual(redeployed, { id: returned.id, location: 'carthag', status: 'placed' }); assertRuledGrummanInventory(game); assert.ok(!JSON.stringify(viewGame(game, 'guild').moritaniTerror).includes(f.placed)); } }); void test('return quote remains available without ADD supply and preserves other placements and spent faces', () => { const f = createRuledGrummanFixture(); const game = structuredClone(f.game); // Pure custody input: all faces are physically placed, so no addition exists. game.moritaniTerror = placeTerror(game.moritaniTerror!, game.moritaniTerror!.tokens.find(t => t.kind === 'sabotage')!.id, 'carthag', 2); for (const token of game.moritaniTerror.tokens) if (token.status === 'available') { token.status = 'placed'; token.location = 'carthag'; } const quote = quoteGrummanCollection(game, f.owner); assert.ok(quote.blocked); assert.equal(quote.removeBlocked, null); assert.equal(quote.removable.length, 6); quote.removable[0].location = 'not-a-location'; assert.notEqual(game.moritaniTerror.tokens[0].location, 'not-a-location'); const before = structuredClone(game); const result = quoteGrummanCollectionAction(game, f.owner, { mode: 'remove', token: f.placed }, () => 0); assert.equal(result.amount, 4); assert.equal(result.state.placementTurn, 2); assert.deepEqual(result.state.tokens.filter(t => t.status === 'placed'), before.moritaniTerror!.tokens.filter(t => t.id !== f.placed)); assert.equal(result.state.tokens.filter(t => t.status === 'available').length, 1); assert.deepEqual(game, before); for (const kind of ['robbery', 'extortion'] as const) { const source = structuredClone(f.game); const selected = source.moritaniTerror!.tokens.find(t => t.kind === kind)!; if (selected.status === 'available') source.moritaniTerror = placeTerror(source.moritaniTerror!, selected.id, 'carthag', 2); source.moritaniTerror = revealTerror(source.moritaniTerror!, selected.id); const invalid = structuredClone(source); assert.throws(() => quoteGrummanCollectionAction(source, f.owner, { mode: 'remove', token: selected.id }, () => 0)); assert.deepEqual(source, invalid); if (kind === 'extortion') { const returned = quoteGrummanCollectionAction(source, f.owner, { mode: 'remove', token: f.placed }, () => 0); assert.deepEqual(returned.state.tokens.find(t => t.id === selected.id), source.moritaniTerror.tokens.find(t => t.id === selected.id)); } } }); void test('stale rotated IDs, inactive Grumman, low native population and foreign owners cannot quote a return', () => { const f = createRuledGrummanFixture(); const action = { mode: 'remove', token: f.placed } as const; const result = quoteGrummanCollectionAction(f.game, f.owner, action, () => 0); const returned = { ...f.game, moritaniTerror: result.state }; assert.throws(() => quoteGrummanCollectionAction(returned, f.owner, action, () => 0)); for (const context of [ { ...f.game, homeworlds: undefined }, { ...f.game, players: f.game.players.map(p => p.id === f.owner ? { ...p, reserves: 7 } : p) }, ]) { const before = structuredClone(context); assert.ok(quoteGrummanCollection(context, f.owner).removeBlocked); assert.deepEqual(quoteGrummanCollection(context, f.owner).removable, []); assert.throws(() => quoteGrummanCollectionAction(context, f.owner, action, () => 0)); assert.deepEqual(context, before); } assert.throws(() => quoteGrummanCollectionAction(f.game, 'guild', action, () => 0)); assert.throws(() => quoteGrummanCollectionAction(f.game, f.owner, { ...action, token: 'missing' }, () => 0)); }); void test('native removal historical frame binds its original ID and territory without requiring a live token', () => { const f = createRuledGrummanFixture(); const game = applyAction(f.game, f.owner, { type: 'decision', event: f.game.grummanCollection!.event, mode: 'remove', token: f.placed }); assert.ok(!game.moritaniTerror!.tokens.some(t => t.id === f.placed)); validateGrummanCollection(game); for (const changes of [ { territory: 'homeworld:moritani' }, { territory: 'mobile_stronghold' }, { token: undefined }, { nexusEvent: 'unearned' }, { outcome: 'decline' as const }, ]) { const bad = structuredClone(game); Object.assign(bad.grummanCollection!, changes); bad.grummanCollection!.signature = grummanCollectionSignature(bad.grummanCollection!); assert.throws(() => validateGrummanCollection(bad)); } });