146 lines
9.1 KiB
TypeScript
146 lines
9.1 KiB
TypeScript
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);
|
|
});
|