import assert from 'node:assert/strict'; import test from 'node:test'; import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { TERRITORIES, gameDistance, location, splitLocation } from '../game/board'; import { ambassadorPlacementDestinations } from '../game/ecaz-ambassador-placement'; import { actTruthMovementNativeEarly as act, nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early'; const seat = (game: Game, id: string) => { const player = game.players.find(p => p.id === id); assert.ok(player, `Missing ${id}`); return player; }; const clean = (game: Game) => !game.response && !game.decision && !game.phaseOpening && !game.truthtrance && !game.pendingTreacheryDiscard; const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)); /** Genuine public Basic setup and live declarations. Original unopened Spice * card positions are selected to avoid random worms; first-turn marker shipment, * Mentat Terror and second-turn Ambassador use the normal player actions. */ function source(value: 0 | 3) { let game = createGame(`REVEALE3${value}`, newPlayer('richese', 'Richese', 'richese'), false, ['choam', 'ecaz']); for (const faction of ['ecaz', 'moritani', 'fremen', 'choam'] as const) joinGame(game, newPlayer(faction, faction, faction)); for (const p of game.players) game = act(game, p.id, {type:'ready'}); game = act(game, game.host, {type:'start'}); assert.equal(game.status, 'setup'); // Select one unopened original card for Ecaz so the CHOAM Charity power // retains its real optional Karama response instead of auto-passing. const karama = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(karama >= 1); [game.deck[1], game.deck[karama]] = [game.deck[karama], game.deck[1]]; let ecazWindow: Game | null = null; let charityWindow: Game | null = null; let ambassadorDeclared = false; let terrorDeclared = false; for (let n = 0; n < 2400; n++) { if (game.status === 'playing' && game.turn === 2 && game.phase === 5 && game.active === 'fremen' && clean(game) && seat(game, 'richese').noField?.deployed) break; assert.notEqual(game.status, 'finished'); if (!charityWindow && game.turn === 2 && game.response?.kind === 'choamCharity') charityWindow = structuredClone(game); if (game.status === 'setup' && game.setupStage === 'forces' && seat(game, 'fremen').reserves === 20 && viewGame(game, 'fremen').setupPending.includes('fremen')) { game = act(game, 'fremen', {type:'fremenSetup', placements:{'sietch_tabr:14':10}}); continue; } if (game.status === 'playing' && game.turn === 1 && game.phase === 5 && game.active === 'richese' && clean(game) && !seat(game, 'richese').shipped && !seat(game, 'richese').noField?.deployed) { const richese = seat(game, 'richese'), token = richese.noField!.tokens.find(t => t.value === value); assert.ok(token && richese.noFieldEvent); game = act(game, 'richese', {type:'ship', noField:token.id, event:richese.noFieldEvent, territory:'polar_sink', sector:0, allyPayment:0}); continue; } if (game.decision?.kind === 'ecazPlacement' && !game.response) { if (game.turn === 2 && !ambassadorDeclared) { ecazWindow = structuredClone(game); const token = game.ecazAmbassadors!.tokens.find(t => t.zone === 'supply'); const destination = ambassadorPlacementDestinations(game)[0]; assert.ok(token && destination); game = act(game, 'ecaz', {type:'decision', token:token.id, territory:destination.id}); ambassadorDeclared = true; } else game = act(game, 'ecaz', {type:'decision', decline:true}); continue; } if (game.decision?.kind === 'moritaniPlacement' && !game.response) { if (game.turn === 1 && !terrorDeclared) { const token = game.moritaniTerror!.tokens.find(t => t.status === 'available'); assert.ok(token); game = act(game, 'moritani', {type:'decision', token:token.id, territory:'carthag'}); terrorDeclared = true; } else game = act(game, 'moritani', {type:'decision', decline:true}); continue; } if (game.status === 'playing' && game.phase === 5 && game.active && clean(game)) { game = act(game, game.active, {type:'endMovement'}); continue; } const next = nextTruthMovementNativeEarlyStep(game); assert.ok(next, `Missing native continuation ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`); game = act(game, next.actor, next.action.type === 'stormDial' ? {...next.action, amount:game.turn === 1 ? 0 : 1} : next.action); if (game.status === 'playing' && game.turn === 1 && game.phase === 0 && game.spiceDeck.length) { for (let pos = 0; pos < 2; pos++) { const at = game.spiceDeck.findIndex((card, index) => index >= pos && 'territory' in card && !card.discovery); assert.ok(at >= pos); [game.spiceDeck[pos], game.spiceDeck[at]] = [game.spiceDeck[at], game.spiceDeck[pos]]; } } } assert.ok(game.turn === 2 && game.phase === 5 && game.active === 'fremen' && clean(game)); assert.ok(ecazWindow && charityWindow && ambassadorDeclared && terrorDeclared, `Missing native window: ${JSON.stringify({ecazWindow:!!ecazWindow,charityWindow:!!charityWindow,ambassadorDeclared,terrorDeclared})}`); assert.ok(game.ecazAmbassadors!.tokens.some(t => t.zone === 'placed')); assert.ok(game.moritaniTerror!.tokens.some(t => t.status === 'placed')); const richese = seat(game, 'richese'), deployed = richese.noField!.deployed; assert.ok(deployed && richese.noFieldEvent); const origin = 'sietch_tabr:14'; const destination = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(s => location(t.id,s))) .find(key => splitLocation(key).sector !== game.storm && game.players.every(p => !(p.forces[key] > 0)) && gameDistance(game, origin, key, k => splitLocation(k).sector === game.storm) === 2); assert.ok(destination); const beforeMove = structuredClone(game); game = act(game, 'fremen', {type:'move', forces:{[origin]:1}, ...splitLocation(destination)}); assert.equal(game.response?.kind, 'fremenMovement'); return {game, beforeMove, ecazWindow, charityWindow, token:deployed.tokenId, event:richese.noFieldEvent, origin, destination}; } function passResponse(game: Game) { for (let n = 0; game.response && n < 12; n++) { const p = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(p); game = act(game, p.id, {type:'passResponse'}); } assert.equal(game.response, null); return game; } for (const value of [0, 3] as const) void test( `Basic Ecaz/Moritani native tokens preserve foreign movement after No-Field ${value} reveal`, () => { const f = source(value), before = structuredClone(f.game); const response = structuredClone(before.response), move = structuredClone(before.pendingFremenMove); const ambassadors = structuredClone(before.ecazAmbassadors), terror = structuredClone(before.moritaniTerror); assert.equal(viewGame(before, 'richese').richeseNoField?.canReveal, true); const reveal: Action = {type:'revealNoField', token:f.token, event:f.event}; assert.throws(() => act(before, 'ecaz', reveal)); const after = act(before, 'richese', reveal); assert.deepEqual(after.response, response); assert.deepEqual(after.pendingFremenMove, move); assert.deepEqual(after.ecazAmbassadors, ambassadors); assert.deepEqual(after.moritaniTerror, terror); assert.equal(seat(after, 'richese').noField?.deployed, null); assert.equal(seat(after, 'richese').reserves, seat(before, 'richese').reserves - value); assert.equal(seat(after, 'richese').forces['polar_sink:0'] ?? 0, value); const control = passResponse(reload(before)), done = passResponse(reload(after)); assert.deepEqual(seat(done, 'fremen'), seat(control, 'fremen')); assert.equal(seat(done, 'fremen').forces[f.destination], 1); assert.equal(seat(done, 'fremen').forces[f.origin], seat(f.beforeMove, 'fremen').forces[f.origin] - 1); assert.equal(seat(done, 'fremen').moved, seat(f.beforeMove, 'fremen').moved + 1); assert.deepEqual(done.ecazAmbassadors, ambassadors); assert.deepEqual(done.moritaniTerror, terror); for (const difficulty of DIFFICULTIES) { const view = viewGame(after, 'ecaz'); view.players.find(p => p.id === 'ecaz')!.bot = difficulty; const action = botActions(view)[0]; assert.ok(action, difficulty); assert.doesNotThrow(() => act(reload(after), 'ecaz', action)); } }, ); void test('a live native Ecaz placement still blocks an unrelated voluntary reveal', () => { const {ecazWindow, token, event} = source(3); assert.ok(ecazWindow); const before = structuredClone(ecazWindow); assert.equal(before.decision?.kind, 'ecazPlacement'); assert.equal(viewGame(before, 'richese').richeseNoField?.canReveal, false); assert.throws(() => act(before, 'richese', {type:'revealNoField', token, event})); assert.deepEqual(before, ecazWindow); }); void test('Basic E3 native Terror remains placed while CHOAM Charity continues after a reveal', () => { const {charityWindow, token, event} = source(3); assert.ok(charityWindow); const before = structuredClone(charityWindow), response = structuredClone(before.response); const terror = structuredClone(before.moritaniTerror); assert.equal(response?.kind, 'choamCharity'); assert.ok(terror?.tokens.some(t => t.status === 'placed')); assert.equal(viewGame(before, 'richese').richeseNoField?.canReveal, true); const after = act(before, 'richese', {type:'revealNoField', token, event}); assert.deepEqual(after.response, response); assert.deepEqual(after.moritaniTerror, terror); const original = passResponse(reload(before)), done = passResponse(reload(after)); assert.equal(seat(done, 'choam').spice, seat(original, 'choam').spice); assert.deepEqual(done.moritaniTerror, terror); assert.equal(seat(done, 'richese').forces['polar_sink:0'], 3); });