Files
Dune/tests/selected-nexus-skills-unused-runtime.test.ts
T
Quintine 00e01a7cd2
Verify and publish checkpoint container / container (push) Successful in 11m54s
Support selected Nexus decks with leader skills
2026-10-29 20:26:46 +11:00

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