import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, viewGame, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { TERRITORIES } from '../game/board'; import { botGroundMoveAllowed, groundMoveRouteDistance } from '../game/bot-mobility'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { basicSemutaAmbassadorInventory as inventory } from './fixture-basic-semuta-ambassador'; import { advanceSelectedIndependentNexus } from './fixture-selected-independent-nexus-cards'; import { selectedDiscoverySkillsStart, advanceDiscoverySkillsBlow } from './selected-discovery-skills-fixture'; function player(game: Game, id: string) { const seat = game.players.find(row => row.id === id); assert.ok(seat); return seat; } const clean = (g: Game) => !g.phaseOpening && !g.response && !g.decision && !g.truthtrance && !g.pendingTreacheryDiscard; /** Public mixed selected-deck start with real training, Discovery blow and * Movement. Only unopened Spice order and conserved card custody are staged. */ function fixture(advanced: boolean) { const started = selectedDiscoverySkillsStart({advanced, factions:['richese', 'ecaz', 'ixians', 'atreides']}); assert.equal(started.setup.semutaPreview, true); assert.ok(started.game.leaderSkills && started.game.discoveries); let game = advanceDiscoverySkillsBlow(started.game, started.actions); game = advanceSelectedIndependentNexus(game, state => state.phase === 5 && state.active === 'atreides' && clean(state), started.actions); const beforeInventory = inventory(game); const give = (id: string, match: (card: Game['deck'][number]) => boolean) => { const target = player(game, id); const zones = [game.deck, game.discard, game.richeseCache!, ...game.players.map(row => row.hand)]; const source = zones.find(zone => zone.some(match)); assert.ok(source); const [card] = source.splice(source.findIndex(match), 1); if (target.hand.length >= 4) source.push(target.hand.pop()!); target.hand.push(card); return card.id; }; give('richese', card => card.id === SEMUTA_DRUG_ID); const hajr = give('atreides', card => card.effect === 'hajr'); assert.deepEqual(inventory(game), beforeInventory); if (advanced) { // The Advanced face requires an actual shipment followed by movement. game = applyAction(game, 'atreides', {type:'ship', territory:'polar_sink', sector:0, amount:1, elite:0, allyPayment:0}); game = advanceSelectedIndependentNexus(game, state => state.phase === 5 && state.active === 'atreides' && clean(state), started.actions); const own = viewGame(game, 'atreides'), seat = own.players.find(row => row.id === 'atreides'); assert.ok(seat); const from = 'polar_sink:0'; const destination = TERRITORIES.filter(place => place.type === 'sand' && !game.players.some(row => Object.keys(row.forces).some(key => key.startsWith(`${place.id}:`))) && !Object.keys(game.spice).some(key => key.startsWith(`${place.id}:`) && game.spice[key] > 0)) .flatMap(place => place.sectors.map(sector => ({territory:place.id, sector}))) .find(place => groundMoveRouteDistance(own, seat, from, `${place.territory}:${place.sector}`) === 1 && botGroundMoveAllowed(own, seat, from, `${place.territory}:${place.sector}`, 0)); assert.ok(destination); game = applyAction(game, 'atreides', {type:'move', from, ...destination, amount:1, elite:0}); game = advanceSelectedIndependentNexus(game, state => state.phase === 5 && state.active === 'atreides' && clean(state), started.actions); assert.equal(player(game, 'atreides').hajrWindow?.turn, game.turn); } return {game, hajr, beforeInventory}; } for (const advanced of [false, true]) void test( `${advanced ? 'Advanced' : 'Basic'} mixed Discovery Skills supports an original Hajr Semuta claim or pass`, () => { const f = fixture(advanced); const prior = structuredClone(f.game); const pending = applyAction(prior, 'atreides', {type:'card', card:f.hajr}); assert.equal(pending.pendingTreacheryDiscard?.batch.cause, 'ordinary:hajr'); assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); const event = pending.pendingTreacheryDiscard!.batch.event; const ownerView = viewGame(pending, 'richese'); assert.equal(ownerView.semutaReaction?.canCommit, true); for (const difficulty of DIFFICULTIES) { const view = viewGame(pending, 'richese'); view.players.find(row => row.id === 'richese')!.bot = difficulty; const action = botActions(view).find(row => row.type === 'semutaCommit' || row.type === 'semutaPass'); assert.ok(action, difficulty); assert.doesNotThrow(() => applyAction(pending, 'richese', action), difficulty); } for (const claim of [false, true]) { let next = structuredClone(pending); if (claim) next = applyAction(next, 'richese', {type:'semutaCommit', event}); else for (const seat of next.players) next = applyAction(next, seat.id, {type:'semutaPass', event}); if (next.pendingTreacheryDiscard?.reaction?.stage === 'select') next = applyAction(next, 'richese', {type:'semutaSelect', event, card:f.hajr}); assert.equal(next.pendingTreacheryDiscard, null); assert.equal(next.active, 'atreides'); assert.ok(next.hajr.includes('atreides')); assert.equal(player(next, 'richese').hand.some(card => card.id === f.hajr), claim); assert.equal(next.discard.some(card => card.id === SEMUTA_DRUG_ID), claim); assert.deepEqual(inventory(next), f.beforeInventory); } });