Files
Dune/tests/choam-skills-integration.test.ts
T

388 lines
12 KiB
TypeScript

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);
});