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 { TERRITORIES } from '../game/board'; import type { FactionId } from '../game/catalog'; import { treacheryDeck } from '../game/cards'; import { validateLeaderSkills } from '../game/leader-skills'; import { validateNexusCards } from '../game/nexus-cards'; import { DISCOVERY_CARD_PLACEMENTS, validateDiscoveryState } from '../game/discoveries'; import { nextPairedChoamSkillsStep } from './fixture-paired-choam-nexus-skills'; import { actSelectedIndependentNexus as act, advanceSelectedIndependentNexus as advance, selectedIndependentNexusClean as clean, type SelectedIndependentNexusStep } from './fixture-selected-independent-nexus-cards'; import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus'; type Options = { factions: readonly FactionId[]; advanced: boolean; discovery?: boolean }; /** A real public lobby with original selected decks and real private training. * Only the original random lottery is fixed; no card or skill is granted. */ function publicNexusSkills(options: Options) { const [host, ...others] = options.factions; let game = withClassicDiscoveryNexusLottery(() => createGame('NEXUSSKILLSUNUSED', newPlayer(host, host, host), options.advanced, ['ix', 'choam', 'ecaz'])); for (const faction of others) joinGame(game, newPlayer(faction, faction, faction)); const actions: SelectedIndependentNexusStep[] = []; const step = (actor: string, action: Action) => { game = act(game, actor, action, actions); }; step(host, {type:'moduleOption', module:'leaderSkills', enabled:true}); step(host, {type:'moduleOption', module:'nexusCards', enabled:true}); if (options.discovery) step(host, {type:'moduleOption', module:'discoveries', enabled:true}); for (const seat of game.players) step(seat.id, {type:'ready'}); const lobby = structuredClone(game); step(host, options.advanced ? {type:'start', advancedPreview:true} : {type:'start'}); const setup = structuredClone(game); for (let count = 0; game.status === 'setup' && count < 300; count++) { const next = nextPairedChoamSkillsStep(game); assert.ok(next, `No original setup control at ${game.setupStage ?? game.decision?.kind}`); step(next.actor, next.action); } assert.equal(game.status, 'playing'); return { lobby, setup, game, actions }; } for (const advanced of [false, true]) for (const factions of [ ['atreides', 'guild', 'harkonnen', 'emperor'], ['ixians', 'ecaz', 'guild'], ] as const) void test(`public ${advanced ? 'Advanced' : 'Basic'} Nexus and Skills with unused selected family: ${factions.join('/')}`, () => { const f = publicNexusSkills({advanced, factions}); const game = f.game; assert.deepEqual(game.expansions, ['ix', 'choam', 'ecaz']); assert.ok(f.actions.some(row => row.action.type === 'moduleOption' && row.action.module === 'nexusCards')); assert.ok(f.actions.some(row => row.action.type === 'moduleOption' && row.action.module === 'leaderSkills')); assert.equal(game.ecazTreachery, undefined); assert.equal(game.leaderSkills!.assignments.length, factions.length); assert.equal(f.actions.filter(row => row.action.type === 'leaderSkill').length, factions.length); validateLeaderSkills(game.leaderSkills!, game.players); validateNexusCards(game.nexusCards!.cards!, game.players); assert.equal(game.nexusCards!.cards!.deck.length, 12); assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)] .map(card => card.id).sort(), treacheryDeck(game.expansions).map(card => card.id).sort()); if (game.players.some(player => player.faction === 'ecaz')) assert.equal(game.ecazOccupyPreview, true); if (game.players.some(player => player.faction === 'ixians')) assert.equal(game.nexusIxianBetrayalPreview, true); if (game.players.some(player => player.faction === 'harkonnen')) assert.equal(game.nexusHarkonnenBetrayalPreview, true); }); void test('public Advanced Nexus/Skills initializes native Moritani assassination with unused selected families', () => { const f = publicNexusSkills({advanced:true, factions:['moritani', 'atreides', 'guild']}); assert.ok(f.game.moritaniAssassinate); assert.equal(f.game.leaderSkills!.assignments.length, 3); assert.ok(f.game.nexusCards?.cards); validateLeaderSkills(f.game.leaderSkills!, f.game.players); validateNexusCards(f.game.nexusCards!.cards!, f.game.players); }); for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} public Nexus/Skills/Discovery with unused selected decks resolves a real Discovery Spice Blow`, () => { const f = publicNexusSkills({advanced, discovery:true, factions:['atreides', 'guild', 'emperor']}); let game = structuredClone(f.game); assert.ok(game.discoveries); assert.ok(game.nexusCards?.cards); assert.equal(game.leaderSkills!.assignments.length, 3); // Reorder original unopened Spice Cards; the real turn supplies placement. for (const position of advanced ? [0, 1] : [0]) { const index = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card && (position === 0 ? card.discovery === 'discovery-hagga-basin' : !card.discovery)); assert.ok(index >= position); [game.spiceDeck[position], game.spiceDeck[index]] = [game.spiceDeck[index], game.spiceDeck[position]]; } game = advance(game, state => state.turn === 1 && state.phase === 5 && clean(state), f.actions); assert.ok(game.spiceDiscard.flat().some(card => 'territory' in card && card.discovery === 'discovery-hagga-basin')); const placed = game.discoveries!.tokens.filter(token => token.status === 'placed'); assert.equal(placed.length, 1); const source = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin']; assert.deepEqual([placed[0].type, placed[0].territory, placed[0].sector], [source.type, source.territory, source.sector]); validateDiscoveryState(game.discoveries!); validateNexusCards(game.nexusCards!.cards!, game.players); validateLeaderSkills(game.leaderSkills!, game.players); }); for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} Nexus/Skills selected-deck table draws and uses a real Nexus card`, () => { const f = publicNexusSkills({advanced, factions:['atreides', 'guild', 'harkonnen', 'emperor']}); let game: Game = structuredClone(f.game); // Reorder only unopened original cards to reach an actual second-turn worm. const take = (worm: boolean) => { const index = game.spiceDeck.findIndex(card => worm ? 'worm' in card && !card.greatMaker && !card.thumper && !card.suppressed : 'territory' in card && !card.discovery); assert.ok(index >= 0); return game.spiceDeck.splice(index, 1)[0]; }; const first = [take(false), ...(advanced ? [take(false)] : [])]; const second = [take(true), take(false), ...(advanced ? [take(false)] : [])]; game.spiceDeck.unshift(...first, ...second); game = advance(game, state => state.turn === 2 && !!state.nexus && !state.spiceWindow && !state.spiceResolution && clean(state), f.actions); game = act(game, 'guild', {type:'alliance', target:'harkonnen'}, f.actions); game = act(game, 'harkonnen', {type:'alliance', target:'guild'}, f.actions); game = advance(game, state => state.nexusCards?.phase?.stage === 'drawing' && clean(state), f.actions); const deck = game.nexusCards!.cards!.deck, index = deck.indexOf('richese'); assert.ok(index >= 0); deck.unshift(deck.splice(index, 1)[0]); const before = structuredClone(game); const view = viewGame(before, 'atreides'); view.players.find(player => player.id === 'atreides')!.bot = 'Easy'; const bot = botActions(view).find(action => action.type === 'nexusCardChoice'); assert.ok(bot); assert.doesNotThrow(() => act(before, 'atreides', bot)); game = act(game, 'atreides', {type:'nexusCardChoice', turn:game.turn, card:game.nexusCards!.cards!.hands.atreides, choice:'draw', ownRedraws:0}, f.actions); assert.equal(game.nexusCards!.cards!.hands.atreides, 'richese'); assert.equal(game.leaderSkills!.assignments.length, 4); game = advance(game, state => state.phase === 5 && state.active === 'atreides' && clean(state), f.actions); const offer = viewGame(game, 'atreides').nexusRichese; assert.ok(offer && !offer.blocked); const place = TERRITORIES.find(territory => territory.type === 'stronghold' && !territory.sectors.includes(game.storm) && game.players.every(player => Object.keys(player.forces).every(key => !key.startsWith(`${territory.id}:`)))); assert.ok(place); game = act(game, 'atreides', {type:'ship', territory:place.id, sector:place.sectors[0], amount:1, elite:0, allyPayment:0, nexus:offer.event}, f.actions); game = advance(game, state => state.nexusRicheseLast?.stage === 'shipped', f.actions); assert.equal(game.nexusCards!.cards!.hands.atreides, null); assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'richese').length, 1); assert.equal(game.players.find(player => player.id === 'atreides')!.forces[`${place.id}:${place.sectors[0]}`], 1); validateNexusCards(game.nexusCards!.cards!, game.players); validateLeaderSkills(game.leaderSkills!, game.players); });