import assert from 'node:assert/strict'; import test from 'node:test'; import { TERRITORIES, location, splitLocation } from '../game/board'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { isPlanetologistBattleSpecialCard } from '../game/leader-skill-combat'; import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards'; import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game, } from '../game/engine'; import { planetologistMoveDraft } from '../components/planetologist-movement'; import { sandmasterMoveDraft } from '../components/sandmaster-movement'; import { assertChoamSkillsCustody, choamSkillsPlayer as player, completedChoamSkillsGame, reloadChoamSkillsGame as reload, rejectChoamSkillsAction as reject, } from './choam-skills-fixture'; function movementPosition(game: Game): Game { for (const participant of game.players) { participant.forces = {}; participant.reserves = 20; participant.tanks = 0; participant.spice = 20; participant.moved = 0; participant.shipped = true; } Object.assign(game, { phase: 5, active: 'c', order: ['c', 'a', 'e'], storm: 18, ready: [], decision: null, response: null, phaseOpening: null, }); return game; } function passResponses(state: Game): Game { let game = state; while (game.response) { const passer = game.players.find((candidate) => { const controls = viewGame(game, candidate.id).responseControls; return controls && !controls.hasPassed && controls.cancelCards.length > 0; }); assert.ok(passer); game = applyAction(game, passer.id, { type: 'passResponse' }); } return game; } function holdByName(game: Game, owner: string, name: string) { const index = game.deck.findIndex((card) => card.name === name); assert.ok(index >= 0, name); const [card] = game.deck.splice(index, 1); player(game, owner).hand.push(card); return card; } void test('all fourteen production skill faces deal through genuine Basic CHOAM setup', () => { for (const skill of LEADER_SKILL_CARDS) { const game = completedChoamSkillsGame({ requestedSkill: skill.id }); assert.equal( game.leaderSkills!.assignments.find( (assignment) => assignment.owner === 'c', )!.skill, skill.id, ); assert.equal( player(game, 'c').leaders.some((leader) => leader.id === 'choam-auditor'), false, ); for (const participant of game.players) { const view = viewGame(game, participant.id); assert.equal(view.leaderSkills!.offer, null); assert.equal(view.leaderSkills!.assignments.length, 3); assert.deepEqual(viewGame(reload(game), participant.id), view); } assertChoamSkillsCustody(game); } }); void test('CHOAM Planetologist and Sandmaster controls and bots submit legal shared movement actions', () => { let game = movementPosition( completedChoamSkillsGame({ requestedSkill: 'planetologist' }), ); const from = 'red_chasm:7'; player(game, 'c').forces = { [from]: 3 }; player(game, 'c').reserves = 17; const target = TERRITORIES.filter((territory) => territory.type === 'sand') .flatMap((territory) => territory.sectors.map((sector) => location(territory.id, sector)), ) .map(splitLocation) .find((at) => { const draft = planetologistMoveDraft( viewGame(game, 'c'), 'range', at.territory, at.sector, { [from]: 3 }, {}, ); return draft.action && at.territory !== 'red_chasm'; }); assert.ok(target); const draft = planetologistMoveDraft( viewGame(game, 'c'), 'range', target.territory, target.sector, { [from]: 3 }, {}, ); assert.equal(draft.blocked, null); assert.ok(draft.action); game.spice[location(target.territory, target.sector)] = 8; for (const difficulty of DIFFICULTIES) { const view = viewGame(game, 'c'); view.players.find((candidate) => candidate.id === 'c')!.bot = difficulty; const action = botActions(view).find( (candidate) => candidate.type === 'move' && candidate.planetologist === 'range', ); assert.ok(action, difficulty); assert.doesNotThrow(() => applyAction(game, 'c', action)); } const moved = applyAction(reload(game), 'c', draft.action); assert.equal( player(moved, 'c').forces[location(target.territory, target.sector)], 3, ); game = movementPosition( completedChoamSkillsGame({ requestedSkill: 'sandmaster' }), ); const sandFrom = 'wind_pass_north:17'; player(game, 'c').forces = { [sandFrom]: 3 }; player(game, 'c').reserves = 17; for (const territory of TERRITORIES) for (const sector of territory.sectors) game.spice[location(territory.id, sector)] = 2; const candidates = TERRITORIES.flatMap((territory) => territory.sectors.map((sector) => ({ territory: territory.id, sector })), ); const control = candidates .map((to) => sandmasterMoveDraft(viewGame(game, 'c'), { type: 'move', territory: to.territory, sector: to.sector, forces: { [sandFrom]: 3 }, }), ) .find((candidate) => candidate.action && candidate.collectible.length > 0); assert.ok(control?.action); assert.doesNotThrow(() => applyAction(reload(game), 'c', control.action!)); for (const difficulty of DIFFICULTIES) { const view = viewGame(game, 'c'); view.players.find((candidate) => candidate.id === 'c')!.bot = difficulty; const action = botActions(view).find( (candidate) => candidate.type === 'move' && candidate.sandmaster, ); assert.ok(action, difficulty); assert.doesNotThrow(() => applyAction(game, 'c', action)); } }); void test('CHOAM Smuggler ships its physical companion and Bureaucrat distinguishes qualifying payments', () => { let game = movementPosition( completedChoamSkillsGame({ requestedSkill: 'smuggler' }), ); player(game, 'c').shipped = false; for (const difficulty of DIFFICULTIES) { const view = viewGame(game, 'c'); view.players.find((candidate) => candidate.id === 'c')!.bot = difficulty; const action = botActions(view).find( (candidate) => candidate.type === 'ship' && candidate.smuggler === true, ); assert.ok(action, difficulty); assert.doesNotThrow(() => applyAction(game, 'c', action)); } const ship: Action = { type: 'ship', territory: 'carthag', sector: 11, amount: 3, smuggler: true, }; game = applyAction(game, 'c', ship); assert.equal(player(game, 'c').forces['carthag:11'], 3); assert.equal(player(game, 'c').reserves, 17); assert.equal(player(game, 'c').spice, 18); assert.deepEqual(normalizeAutomaticGame(reload(game)), game); assertChoamSkillsCustody(game); game = movementPosition( completedChoamSkillsGame({ requestedSkill: 'bureaucrat' }), ); const own = applyAction(game, 'c', { type: 'bribe', target: 'e', amount: 5 }); assert.notEqual(own.decision?.kind, 'bureaucratPayment'); const small = applyAction(game, 'a', { type: 'bribe', target: 'e', amount: 4, }); assert.notEqual(small.decision?.kind, 'bureaucratPayment'); const bank = reload(game); bank.active = 'a'; player(bank, 'a').shipped = false; const smallBankPayment = applyAction(bank, 'a', { type: 'ship', territory: 'carthag', sector: 11, amount: 1, }); assert.notEqual(smallBankPayment.decision?.kind, 'bureaucratPayment'); game = applyAction(game, 'a', { type: 'bribe', target: 'e', amount: 5 }); assert.equal(game.decision?.kind, 'bureaucratPayment'); assert.equal(game.decision.player, 'c'); const event = game.bureaucratPaymentEvent; const done = applyAction(reload(game), 'c', { type: 'decision', event, redirect: true, }); assert.equal(player(done, 'a').spice, 15); assert.equal(player(done, 'e').bribes, 3); assert.equal(done.bureaucratPayments!.used.length, 1); reject(done, 'c', { type: 'decision', event, redirect: true }); assertChoamSkillsCustody(done); }); void test('native Worthless power executes through its response and preserves physical custody', () => { let game = movementPosition( completedChoamSkillsGame({ requestedSkill: 'warmaster' }), ); player(game, 'c').shipped = false; const kulon = holdByName(game, 'c', 'Kulon'); holdByName(game, 'e', 'Karama'); game = applyAction(game, 'c', { type: 'card', mode: 'choam', card: kulon.id, }); assert.equal(game.response?.kind, 'choamWorthless'); assert.equal( player(game, 'c').hand.some((card) => card.id === kulon.id), true, ); game = passResponses(game); assert.equal(game.choamMovement?.bonus, 1); assert.equal(game.discard.filter((card) => card.id === kulon.id).length, 1); assertChoamSkillsCustody(game); }); void test('native Worthless market sale pays from the bank and preserves the 35-card skill game', () => { let game = completedChoamSkillsGame({ requestedSkill: 'warmaster' }); const worthless = holdByName(game, 'c', 'Baliset'); holdByName(game, 'e', 'Karama'); const spiceBefore = player(game, 'c').spice; Object.assign(game, { phase: 2, active: null, ready: [], decision: null, response: null, phaseOpening: null, choamCharity: { turn: game.turn, canceled: false }, }); for (const participant of game.players) game = applyAction(game, participant.id, { type: 'ready' }); assert.equal(game.decision?.kind, 'choamMarket'); game = applyAction(game, 'c', { type: 'decision', mode: 'sell', card: worthless.id, }); assert.equal(game.response?.kind, 'choamSale'); assert.equal( player(game, 'c').hand.some((card) => card.id === worthless.id), true, ); game = passResponses(game); assert.equal(player(game, 'c').spice, spiceBefore + 2); assert.equal( game.discard.filter((card) => card.id === worthless.id).length, 1, ); assert.equal(game.bureaucratPayments?.pending, undefined); assertChoamSkillsCustody(game); }); void test('a real concealed Planetologist green Special is discarded once after battle', () => { let game = completedChoamSkillsGame({ requestedSkill: 'planetologist' }); const assignment = game.leaderSkills!.assignments.find( (candidate) => candidate.owner === 'c', )!; for (const participant of game.players) { game.deck.push(...participant.hand); participant.hand = []; participant.forces = { 'arrakeen:10': 5 }; participant.reserves = 15; } for (const name of ['Poison Tooth', 'Artillery Strike']) { const ordinary = game.deck.find((card) => card.name === name); assert.ok(ordinary); assert.equal(isPlanetologistBattleSpecialCard(ordinary), false); } const specialIndex = game.deck.findIndex(isPlanetologistBattleSpecialCard); assert.ok(specialIndex >= 0); const [special] = game.deck.splice(specialIndex, 1); player(game, 'c').hand.push(special); assert.equal( ['Poison Tooth', 'Artillery Strike'].includes(special.name), false, ); Object.assign(game, { phase: 6, storm: 18, order: ['c', 'a', 'e'], active: 'c', ready: [], decision: null, response: null, phaseOpening: null, }); game = applyAction(game, 'c', { type: 'chooseBattle', territory: 'arrakeen', target: 'a', }); if (game.decision?.kind === 'mentatQuestion') game = applyAction(game, game.decision.player, { type: 'decision', event: game.decision.event, decline: true, }); while (game.decision?.kind === 'leaderSkillVisibility') { const decision = game.decision; game = applyAction(game, decision.player, { type: 'leaderSkillVisibility', event: decision.event, hide: decision.player === 'c', }); } while (game.battle?.preparation) game = applyAction(game, game.battle.preparation.owner, { type: 'declineBattlePower', }); game = applyAction(game, 'c', { type: 'battlePlan', dial: 4, leader: assignment.leader, weapon: special.id, }); game = applyAction(game, 'a', { type: 'battlePlan', dial: 0, leader: player(game, 'a').leaders.find( (leader) => leader.id !== game.leaderSkills!.assignments.find( (candidate) => candidate.owner === 'a', )!.leader, )!.id, }); game = applyAction(game, 'c', { type: 'traitorCall', call: false }); game = applyAction(game, 'a', { type: 'traitorCall', call: false }); assert.equal(game.discard.filter((card) => card.id === special.id).length, 1); assert.equal( player(game, 'c').hand.some((card) => card.id === special.id), false, ); assert.equal(game.pendingWinnerDiscards, null); assertChoamSkillsCustody(game); });