Support mixed Discovery decks with Leader Skills
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-30 17:49:26 +11:00
1 parent 7c1f4b4e09
commit 77eb2392eb
15 files changed
+420 -11

No files matched your search

+5 -3
View File
@@ -94,9 +94,11 @@ void test('Discovery cannot redeal started components or silently remove another
for (const game of overlays) assert.throws(() => initializeDiscoveryGameForAudit(game), RuleError);
});
void test('Discovery Skills cannot silently drop unsupported native families or unrelated modules and previews', () => {
const unsupported = lobby(['ixians', 'ecaz', 'guild'], ['ix', 'ecaz']);
assert.throws(() => initializeLeaderSkillsGameForAudit(unsupported), RuleError);
void test('Discovery Skills retain selected native families and reject invalid phase modules', () => {
const selected = lobby(['ixians', 'ecaz', 'guild'], ['ix', 'ecaz']);
const started = initializeLeaderSkillsGameForAudit(selected);
assert.ok(started.leaderSkills && started.discoveries);
assert.deepEqual(started.players.map(player => player.faction), ['ixians', 'ecaz', 'guild']);
const basicStrongholds = lobby(['guild', 'emperor', 'harkonnen'], []);
basicStrongholds.advanced = false; basicStrongholds.strongholdCards = createStrongholdCards();
assert.throws(() => initializeLeaderSkillsGameForAudit(basicStrongholds), RuleError);
@@ -0,0 +1,63 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import type { FactionId } from '../game/catalog';
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
import { nextPairedChoamSkillsStep } from './fixture-paired-choam-nexus-skills';
import { actSelectedIndependentNexus, advanceSelectedIndependentNexus,
type SelectedIndependentNexusStep } from './fixture-selected-independent-nexus-cards';
export type DiscoverySkillsOptions = {
advanced: boolean;
factions: readonly FactionId[];
expansions?: Game['expansions'];
};
/** Fresh public lobby, real original private training and native setup. Only
* the original random lottery is fixed; no cards, leaders or forces are added. */
export function selectedDiscoverySkillsLobby(options: DiscoverySkillsOptions) {
const [host, ...others] = options.factions;
const expansions = options.expansions ?? ['ecaz', 'ix', 'choam'];
let game = withClassicDiscoveryNexusLottery(() => createGame('SELECTEDDISCOVERYSKILLS',
newPlayer(host, host, host), options.advanced, expansions));
for (const faction of others) joinGame(game, newPlayer(faction, faction, faction));
const actions: SelectedIndependentNexusStep[] = [];
const step = (actor: string, action: Action) => {
game = actSelectedIndependentNexus(game, actor, action, actions);
};
if (expansions.includes('ix') && expansions.includes('choam'))
step(host, {type:'moduleOption', module:'kullPreview', enabled:false});
step(host, {type:'moduleOption', module:'leaderSkills', enabled:true});
step(host, {type:'moduleOption', module:'discoveries', enabled:true});
for (const player of game.players) step(player.id, {type:'ready'});
return {game, host, step, actions};
}
export function selectedDiscoverySkillsStart(options: DiscoverySkillsOptions) {
const fixture = selectedDiscoverySkillsLobby(options);
let game = fixture.game;
const lobby = structuredClone(game);
const start: Action = options.advanced ? {type:'start', advancedPreview:true} : {type:'start'};
game = actSelectedIndependentNexus(game, fixture.host, start, fixture.actions);
const setup = structuredClone(game);
for (let count = 0; game.status === 'setup' && count < 300; count++) {
const next = nextPairedChoamSkillsStep(game);
assert.ok(next, `Original setup must expose a choice at ${game.setupStage ?? game.decision?.kind}`);
game = actSelectedIndependentNexus(game, next.actor, next.action, fixture.actions);
}
assert.equal(game.status, 'playing');
return {...fixture, game, lobby, setup};
}
/** Reorder only the unopened original Spice Cards. The engine resolves the
* Discovery and the following bidding and movement through ordinary actions. */
export function advanceDiscoverySkillsBlow(game: Game, actions: SelectedIndependentNexusStep[]) {
for (const position of game.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]];
}
return advanceSelectedIndependentNexus(game, state => state.turn === 1 && state.phase === 5 &&
!state.phaseOpening && !state.response && !state.decision && !state.truthtrance &&
!state.pendingTreacheryDiscard, actions);
}
@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import { placeAmbassador } from '../game/ecaz-ambassadors';
import { placeTerror } from '../game/moritani-terror';
import { actBasicSemutaAmbassador as act, basicSemutaAmbassadorClean as clean,
basicSemutaAmbassadorPlayer as player, nextBasicSemutaAmbassadorStep } from './fixture-basic-semuta-ambassador';
import { nextPairedChoamSkillsStep } from './fixture-paired-choam-nexus-skills';
export { act, clean, player };
/** Real public mixed-family setup and skills training. Only unopened Spice
* order and original Ambassador/Terror placements are controlled. */
export function selectedDiscoverySkillsOverlapFixture(advanced: boolean) {
let game = createGame('DISCOVERYSKILLSOVERLAP', newPlayer('ecaz', 'Ecaz', 'ecaz'), advanced,
['ecaz', 'ix', 'choam']);
for (const faction of ['moritani', 'ixians', 'choam', 'guild'] as const)
joinGame(game, newPlayer(faction, faction, faction));
game = act(game, 'ecaz', {type:'moduleOption', module:'kullPreview', enabled:false});
game = act(game, 'ecaz', {type:'moduleOption', module:'discoveries', enabled:true});
game = act(game, 'ecaz', {type:'moduleOption', module:'leaderSkills', enabled:true});
for (const seat of game.players) game = act(game, seat.id, {type:'ready'});
const lobby = structuredClone(game);
game = act(game, 'ecaz', {type:'start', ...(advanced ? {advancedPreview:true} : {})});
const setup = structuredClone(game);
const advance = (initial: Game, until: (state: Game) => boolean) => {
let state = initial;
for (let count = 0; count < 1800; count++) {
if (until(state)) return state;
assert.notEqual(state.status, 'finished');
const step = state.status === 'setup' && state.setupStage === 'leaderSkills'
? nextPairedChoamSkillsStep(state) : nextBasicSemutaAmbassadorStep(state);
assert.ok(step, `No original continuation at ${state.status}/${state.phase}/${state.decision?.kind}`);
state = act(state, step.actor, step.action);
}
throw Error('The mixed Discovery/Skills game did not reach its requested boundary');
};
game = advance(game, state => state.status === 'playing');
assert.equal(game.leaderSkills?.assignments.length, game.players.length);
assert.ok(game.discoveries && game.ecazAmbassadors && game.moritaniTerror);
assert.ok(!game.nexusCards && !game.ecazTreachery);
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 && state.active === 'guild' && clean(state));
const beforeMarkers = structuredClone(game);
const token = game.ecazAmbassadors!.tokens.find(row => row.effect === 'ecaz' && row.zone === 'supply');
const terror = game.moritaniTerror!.tokens.find(row => row.kind === 'robbery' && row.status === 'available');
assert.ok(token && terror);
const destination = 'habbanya_ridge_sietch', sector = 17;
game.ecazAmbassadors = placeAmbassador(game.ecazAmbassadors!, token.id, {
turn:game.turn, availableSpice:player(game,'ecaz').spice,
destination:{id:destination,stronghold:true,inStorm:false,allowed:true},
}).state;
game.moritaniTerror = placeTerror(game.moritaniTerror!, terror.id, destination, game.turn);
const shipment: Action = {type:'ship', territory:destination, sector, amount:1, elite:0, allyPayment:0};
return {game, lobby, setup, beforeMarkers, destination, sector, token:token.id,
terror:terror.id, shipment, advance};
}
@@ -0,0 +1,57 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { act, player, selectedDiscoverySkillsOverlapFixture } from './selected-discovery-skills-overlap-fixture';
for (const advanced of [false, true]) void test(
`${advanced ? 'Advanced' : 'Basic'} mixed Discovery and Skills retain the original Ecaz/Moritani arrival`, () => {
const f = selectedDiscoverySkillsOverlapFixture(advanced);
assert.equal(f.lobby.discoveryEnabled, true);
assert.ok(f.setup.discoveries && f.setup.leaderSkills);
assert.equal(f.game.leaderSkills!.assignments.length, f.game.players.length);
assert.ok(f.beforeMarkers.spiceDiscard.flat().some(card =>
'territory' in card && card.discovery === 'discovery-hagga-basin'));
assert.equal(f.beforeMarkers.discoveries!.tokens.filter(token => token.status === 'placed').length, 1);
assert.ok(!f.game.nexusCards && !f.game.ecazTreachery);
const moritaniSpice = player(f.game, 'moritani').spice;
const duke = structuredClone(f.game.dukeVidal!.leader);
let game = act(f.game, 'guild', f.shipment);
game = f.advance(game, state => !!state.pendingArrivalOverlap);
const afterShipment = player(game, 'guild').spice;
const stolen = Math.ceil(afterShipment / 2);
assert.equal(player(game, 'guild').forces[`${f.destination}:${f.sector}`], 1);
const first = game.pendingArrivalOverlap!.first;
const responder = first === 'ambassador' ? 'ecaz' : 'moritani';
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, responder);
view.players.find(row => row.id === responder)!.bot = difficulty;
const choice = botActions(view)[0]; assert.ok(choice, difficulty);
assert.doesNotThrow(() => act(game, responder, choice), difficulty);
}
const ambassador = () => {
const entry = game.pendingAmbassador; assert.ok(entry);
assert.equal(entry.token, f.token);
game = act(game, 'ecaz', {type:'decision', event:entry.event, trigger:true, beneficiary:'ecaz', choice:'duke'});
};
const robbery = () => {
assert.equal(game.pendingTerrorEntry?.token, f.terror);
game = act(game, 'moritani', {type:'decision', reveal:true});
game = act(game, 'moritani', {type:'decision', choice:'spice'});
};
if (first === 'ambassador') { ambassador(); robbery(); }
else { robbery(); ambassador(); }
assert.equal(game.pendingArrivalOverlap ?? null, null);
assert.equal(game.pendingAmbassador ?? null, null);
assert.equal(game.pendingTerrorEntry ?? null, null);
assert.equal(player(game, 'guild').spice, afterShipment - stolen);
assert.equal(player(game, 'moritani').spice, moritaniSpice + stolen);
assert.equal(player(game, 'guild').forces[`${f.destination}:${f.sector}`], 1);
assert.equal(game.moritaniTerror!.tokens.find(row => row.id === f.terror)?.status, 'removed');
assert.equal(game.ecazAmbassadors!.tokens.find(row => row.id === f.token)?.zone, 'supply');
assert.deepEqual([game.dukeVidal?.controller, game.dukeVidal?.source], ['ecaz', 'ecaz']);
assert.deepEqual(game.dukeVidal?.leader, duke);
assert.equal(game.leaderSkills!.assignments.length, game.players.length);
},
);
@@ -0,0 +1,92 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { bureaucratPaymentModeSupported } from '../game/bureaucrat-payment';
import { discoveryModeSupported, selectedDiscoveryLeaderSkillsProfile } from '../game/discovery-module-profile';
import { selectedDiscoveryWithoutCardsProfile, type FactionModuleProfile } from '../game/faction-module-profile';
import { advancedMoritaniLeaderSkillsProfile, basicMoritaniLeaderSkillsProfile,
nativeDiscoveryLeaderSkillsProfile, nativeEcazLeaderSkillsProfile,
ordinaryLeaderSkillModeSupported, strongholdLeaderSkillsProfile } from '../game/leader-skill-profile';
const base: FactionModuleProfile = {
advanced: false,
expansions: ['ix', 'choam', 'ecaz'],
players: [{faction:'atreides'}, {faction:'guild'}, {faction:'emperor'}],
discoveryEnabled: true,
leaderSkills: {},
};
void test('selected Discovery and Skills use the shared registered roster without requiring native seats', () => {
const rows: FactionModuleProfile[] = [
base,
{...base, advanced:true},
{...base, expansions:['ecaz'], players:[{faction:'atreides'}, {faction:'guild'}]},
{...base, expansions:['ix', 'ecaz'], players:[{faction:'ixians'}, {faction:'guild'}]},
{...base, players:[{faction:'ixians'}, {faction:'ecaz'}, {faction:'moritani'}]},
{...base, players:[{faction:'ixians'}, {faction:'ecaz'}, {faction:'guild'}],
discoveries:{}, discoveryStash:{}, greatMaker:{}, ecazOccupyPreview:true},
{...base, advanced:true, players:[{faction:'moritani'}, {faction:'atreides'}, {faction:'guild'}],
moritaniAssassinatePreview:true},
{...base, advanced:true, homeworlds:{}, techTokens:{}, strongholdCards:{}},
{...base, advanced:true, players:[{faction:'harkonnen'}, {faction:'ixians'}]},
];
for (const row of rows) {
const label = JSON.stringify(row);
assert.equal(selectedDiscoveryWithoutCardsProfile(row), true, label);
assert.equal(selectedDiscoveryLeaderSkillsProfile(row), true, label);
assert.equal(discoveryModeSupported(row), true, label);
assert.equal(nativeDiscoveryLeaderSkillsProfile(row), true, label);
assert.equal(ordinaryLeaderSkillModeSupported(row), true, label);
assert.equal(bureaucratPaymentModeSupported(row), true, label);
}
// The pre-deal lobby has not allocated the physical Skills deck yet.
const lobby: FactionModuleProfile = {...base, leaderSkills:undefined};
assert.equal(selectedDiscoveryLeaderSkillsProfile(lobby), true);
assert.equal(strongholdLeaderSkillsProfile({...base, advanced:true, strongholdCards:{}}), true);
});
void test('selected Discovery Skills preserve native Ecaz and Moritani participation', () => {
const pair = {...base, players:[{faction:'ecaz'}, {faction:'moritani'}, {faction:'guild'}]};
assert.equal(nativeEcazLeaderSkillsProfile(pair), true);
assert.equal(basicMoritaniLeaderSkillsProfile(pair), true);
assert.equal(advancedMoritaniLeaderSkillsProfile(pair), false);
const advanced = {...pair, advanced:true, moritaniAssassinatePreview:true};
assert.equal(nativeEcazLeaderSkillsProfile(advanced), true);
assert.equal(advancedMoritaniLeaderSkillsProfile(advanced), true);
});
void test('selected Discovery Skills reject illegal seats, decks, overlays and phase-module prerequisites', () => {
const rejected: FactionModuleProfile[] = [
{...base, discoveryEnabled:false},
{...base, expansions:[]},
{...base, expansions:['ix', 'ix']},
{...base, expansions:['ix', 'unknown']},
{...base, players:[{faction:'atreides'}]},
{...base, players:[{faction:'atreides'}, {faction:'atreides'}]},
{...base, players:[{faction:'atreides'}, {faction:'unknown'}]},
{...base, expansions:['ix'], players:[{faction:'choam'}, {faction:'atreides'}]},
{...base, players:[{faction:'atreides'}, {faction:'guild'}], techTokens:{}},
{...base, strongholdCards:{}},
{...base, nexusCards:{}},
{...base, ecazTreachery:true},
{...base, semutaPreview:true},
{...base, kullPreview:true},
{...base, nexusKullPreview:true},
{...base, guildBetrayalPreview:true},
{...base, nexusIxianBetrayalPreview:true},
{...base, moritaniAssassinatePreview:true},
];
for (const row of rejected) {
const label = JSON.stringify(row);
assert.equal(selectedDiscoveryWithoutCardsProfile(row), false, label);
assert.equal(selectedDiscoveryLeaderSkillsProfile(row), false, label);
}
});
void test('Advanced trained Harkonnen with actual E3 native remains excluded', () => {
for (const faction of ['ecaz', 'moritani']) {
const players = [{faction:'harkonnen'}, {faction}];
assert.equal(selectedDiscoveryLeaderSkillsProfile({...base, players}), true, faction);
assert.equal(selectedDiscoveryLeaderSkillsProfile({...base, players, advanced:true}), false, faction);
assert.equal(discoveryModeSupported({...base, players, advanced:true}), false, faction);
}
});
@@ -0,0 +1,72 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { treacheryDeck } from '../game/cards';
import { DISCOVERY_CARD_PLACEMENTS, validateDiscoveryState } from '../game/discoveries';
import { validateLeaderSkills } from '../game/leader-skills';
import { selectedDiscoverySkillsLobby, selectedDiscoverySkillsStart,
advanceDiscoverySkillsBlow } from './selected-discovery-skills-fixture';
for (const advanced of [false, true]) for (const factions of [
['atreides', 'guild', 'emperor'],
['ixians', 'ecaz', 'guild'],
['moritani', 'choam', 'guild'],
['ecaz', 'moritani', 'guild'],
] as const) void test(
`public ${advanced ? 'Advanced' : 'Basic'} mixed Discovery/Skills: ${factions.join('/')}`, () => {
const fixture = selectedDiscoverySkillsStart({advanced, factions});
const game = fixture.game;
assert.deepEqual(game.expansions, ['ecaz', 'ix', 'choam']);
assert.equal(game.discoveryEnabled, true);
assert.ok(game.discoveries);
assert.equal(game.discoveries.tokens.length, 8);
assert.ok(!game.nexusCards && !game.ecazTreachery);
assert.equal(game.leaderSkills!.assignments.length, factions.length);
assert.equal(fixture.actions.filter(step => step.action.type === 'leaderSkill').length, factions.length);
assert.ok(fixture.actions.some(step => step.action.type === 'moduleOption' && step.action.module === 'discoveries'));
validateLeaderSkills(game.leaderSkills!, game.players);
validateDiscoveryState(game.discoveries!);
assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(player => player.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);
assert.ok(game.ecazAmbassadors);
}
if (game.players.some(player => player.faction === 'moritani')) {
assert.ok(game.moritaniTerror);
if (advanced) assert.ok(game.moritaniAssassinate);
}
if (game.players.some(player => player.faction === 'ixians')) assert.ok(game.mobileStronghold);
});
for (const advanced of [false, true]) void test(
`${advanced ? 'Advanced' : 'Basic'} mixed Discovery/Skills reaches a real Discovery Spice Blow`, () => {
const fixture = selectedDiscoverySkillsStart({advanced, factions:['ixians', 'ecaz', 'guild']});
const game = advanceDiscoverySkillsBlow(structuredClone(fixture.game), fixture.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!);
validateLeaderSkills(game.leaderSkills!, game.players);
assert.ok(!game.nexusCards && !game.ecazTreachery);
assert.ok(game.active);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, game.active);
view.players.find(player => player.id === game.active)!.bot = difficulty;
const action = botActions(view)[0]; assert.ok(action, difficulty);
assert.doesNotThrow(() => applyAction(game, game.active!, action), difficulty);
}
});
for (const faction of ['ecaz', 'moritani'] as const) void test(
`Advanced mixed Discovery/Skills with Harkonnen and ${faction} stays guarded`, () => {
const fixture = selectedDiscoverySkillsLobby({advanced:true, factions:[faction, 'harkonnen', 'ixians']});
const before = structuredClone(fixture.game);
assert.throws(() => applyAction(fixture.game, fixture.host, {type:'start', advancedPreview:true}));
assert.deepEqual(fixture.game, before);
});