import test from 'node:test'; import assert from 'node:assert/strict'; import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { ownedTech, TECH_TOKENS } from '../game/tech-tokens'; import { defaultKullInventory } from './fixture-default-choam-kull'; import { kullPurposeClean, nextKullPurposeStep } from './fixture-kull-purpose'; const player = (game: Game, id: string) => { const result = game.players.find(seat => seat.id === id); assert.ok(result); return result; }; const held = (game: Game, id: string, card: string) => player(game, id).hand.some(item => item.id === card); /** Continue actual setup, first Storm, auctions and turns. Tech's public choice * takes priority; the existing native driver handles the remaining decisions. */ function advance(game: Game, until: (game: Game) => boolean) { for (let count = 0; count < 1600; count++) { if (until(game)) return game; assert.notEqual(game.status, 'finished'); const decision = game.decision; const step = !game.phaseOpening && !game.response && decision?.kind === 'techToken' ? { actor: decision.player, action: { type: 'decision' as const, token: decision.choices[0] } } : nextKullPurposeStep(game); assert.ok(step, 'Native setup or turn requires a legal next action'); game = applyAction(game, step.actor, step.action); } throw new Error('Native Tech/Kull programme did not reach its boundary'); } /** Three-seat, public Basic setup and native actions throughout. Only unopened * starting-deck order and one safe unopened Spice card are selected. No played * game hands, wallets, Tech ownership, phases or turn order are staged. */ function fixture() { let game = createGame('KULLTECH', newPlayer('choam', 'CHOAM', 'choam'), false, ['choam', 'ix']); for (const faction of ['emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction)); game = applyAction(game, 'choam', { type: 'techTokens', enabled: true }); assert.equal(viewGame(game, 'choam').lobbyModules?.kullDefault, true); assert.equal(viewGame(game, 'choam').lobbyModules?.kullPreview, true); for (const seat of game.players) game = applyAction(game, seat.id, { type: 'ready' }); game = applyAction(game, 'choam', { type: 'start' }); assert.equal(game.status, 'setup'); assert.equal(game.kullPreview, true); assert.ok(game.techTokens); assert.ok(game.players.every(seat => seat.hand.length === 0)); const cards = defaultKullInventory(game); const selected: Game['deck'] = []; for (const kind of ['kull', 'karama', 'karama']) { const index = game.deck.findIndex(card => kind === 'kull' ? card.id === 'ix-kull-wahad' : card.effect === 'karama'); assert.ok(index >= 0); selected.push(game.deck.splice(index, 1)[0]); } game.deck.unshift(...selected); const [kull, karama, counter] = selected.map(card => card.id); game = advance(game, state => state.status === 'playing'); assert.ok(held(game, 'choam', kull)); assert.ok(held(game, 'emperor', karama)); assert.ok(held(game, 'guild', counter)); const safe = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(safe >= 0); game.spiceDeck.unshift(game.spiceDeck.splice(safe, 1)[0]); game = advance(game, state => state.phase === 5 && state.active === 'emperor' && kullPurposeClean(state)); const owners = TECH_TOKENS.map(token => game.techTokens![token.id].owner); assert.equal(new Set(owners).size, 3, 'The real first Storm assigned all three physical Tech tokens'); assert.ok(owners.every(id => game.players.some(seat => seat.id === id))); assert.deepEqual(defaultKullInventory(game), cards); return { game, cards, kull, karama, counter, tech: structuredClone(game.techTokens!) }; } type Fixture = ReturnType; function offer(f: Fixture) { const game = applyAction(f.game, 'emperor', { type: 'card', mode: 'shipment', card: f.karama, target: 'emperor', }); assert.equal(game.pendingKull?.stage, 'offer'); assert.ok(held(game, 'emperor', f.karama)); assert.deepEqual(game.techTokens, f.tech); const reaction = viewGame(game, 'choam').kullReaction; assert.ok(reaction?.plays.some(play => play.source === 'printed' && play.card.id === f.kull && !play.blocked)); return game; } function declare(f: Fixture, game: Game) { const reaction = viewGame(game, 'choam').kullReaction; assert.ok(reaction); return applyAction(game, 'choam', { type: 'kullDecision', event: reaction.event, source: 'printed', card: f.kull }); } function shipAndCheckTech(game: Game, f: Fixture) { const before = player(game, 'emperor'); const reserves = before.reserves; const owner = game.techTokens!.heighliners.owner!; const existing = game.techTokens!.heighliners.triggeredTurn === game.turn; const expectedSpice = existing ? game.techTokens!.heighliners.spice : ownedTech(game.techTokens, owner).length; game = applyAction(game, 'emperor', { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1 }); game = advance(game, state => kullPurposeClean(state)); assert.equal(player(game, 'emperor').reserves, reserves - 1); assert.equal(game.techTokens!.heighliners.owner, owner); assert.equal(game.techTokens!.heighliners.spice, expectedSpice); assert.equal(game.techTokens!.heighliners.triggeredTurn, game.turn); assert.deepEqual(defaultKullInventory(game), f.cards); } void test('fresh Basic Tech selection keeps the default Kull offer and four legal bot responses', () => { const f = fixture(), pending = offer(f); for (const difficulty of DIFFICULTIES) { const view = viewGame(pending, 'choam'); view.players.find(seat => seat.id === 'choam')!.bot = difficulty; const action = botActions(view)[0]; assert.ok(action, difficulty); assert.equal(action.type, 'kullDecision'); assert.doesNotThrow(() => applyAction(pending, 'choam', action)); } }); void test('the host can opt out of default Kull in a fresh Tech lobby', () => { let game = createGame('KULLTECHOFF', newPlayer('choam', 'CHOAM', 'choam'), false, ['choam', 'ix']); for (const faction of ['emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction)); game = applyAction(game, 'choam', { type: 'techTokens', enabled: true }); game = applyAction(game, 'choam', { type: 'moduleOption', module: 'kullPreview', enabled: false }); assert.equal(viewGame(game, 'choam').lobbyModules?.kullPreview, false); for (const seat of game.players) game = applyAction(game, seat.id, { type: 'ready' }); game = applyAction(game, 'choam', { type: 'start' }); assert.equal(game.kullPreview ?? false, false); assert.ok(game.techTokens); }); void test('declining Kull resumes the original Karama and an actual Tech-bearing shipment', () => { const f = fixture(), pending = offer(f); let game = applyAction(pending, 'choam', { type: 'kullDecision', event: pending.pendingKull!.event, decline: true }); game = advance(game, kullPurposeClean); assert.equal(game.karamaShipping?.owner, 'emperor'); assert.ok(held(game, 'choam', f.kull)); assert.equal(game.discard.filter(card => card.id === f.karama).length, 1); assert.deepEqual(game.techTokens, f.tech); shipAndCheckTech(game, f); }); void test('successful Kull retains the attempted Karama and an ordinary shipment still earns Tech income', () => { const f = fixture(); const game = advance(declare(f, offer(f)), kullPurposeClean); assert.equal(game.karamaShipping, null); assert.ok(held(game, 'emperor', f.karama)); assert.equal(game.discard.filter(card => card.id === f.kull).length, 1); assert.ok(viewGame(game, 'emperor').karamaBlocked); assert.deepEqual(game.techTokens, f.tech); shipAndCheckTech(game, f); }); void test('a distinct Karama counters Kull without replacing Tech custody or the original shipment', () => { const f = fixture(), pending = declare(f, offer(f)); assert.ok(viewGame(pending, 'guild').responseControls?.cancelCards.includes(f.counter)); let game = applyAction(pending, 'guild', { type: 'card', mode: 'cancel', card: f.counter }); game = advance(game, kullPurposeClean); assert.equal(game.karamaShipping?.owner, 'emperor'); assert.ok(held(game, 'choam', f.kull)); assert.equal(game.discard.filter(card => card.id === f.karama).length, 1); assert.equal(game.discard.filter(card => card.id === f.counter).length, 1); assert.deepEqual(game.techTokens, f.tech); shipAndCheckTech(game, f); });