Support unused selected decks with Nexus and Discovery
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 20:19:58 +11:00
1 parent c8dbd44975
commit 91d45d1dec
14 files changed
+405 -17

No files matched your search

+8
View File
@@ -38,6 +38,14 @@ function originalLoss(game: Game): void {
}
function reviveOriginalE3Loss(game: Game): Game {
game = advanceE3IndependentNexusCards(game, state => state.turn === 3 && state.phase === 4 && clean(state));
// Advanced Revival now follows the native player order. Finish preceding
// opportunities normally before making Moritani's own revival request.
while (game.revivalOrder && game.revivalOrder.remaining[0] !== 'moritani') {
assert.ok(game.revivalOrder.remaining.includes('moritani'));
const actor = game.revivalOrder.remaining[0]; assert.ok(actor);
game = actE3IndependentNexusCard(game, actor, { type: 'ready' });
game = advanceE3IndependentNexusCards(game, state => state.phase === 4 && clean(state));
}
const spice = player(game, 'moritani').spice;
assert.equal(player(game, 'moritani').tanks, 9);
const play = recruitsPlayAction(viewGame(game, 'moritani').recruitsPreview); assert.ok(play);
+80
View File
@@ -0,0 +1,80 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action } from '../game/engine';
import { botActions } from '../game/bots';
import type { FactionId } from '../game/catalog';
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
import { advanceSelectedIndependentNexus, actSelectedIndependentNexus,
selectedIndependentNexusClean } from './fixture-selected-independent-nexus-cards';
export interface SelectedNexusNoCardsOptions {
factions: readonly FactionId[];
advanced?: boolean;
discovery?: boolean;
}
/** Uses the public lobby controls and real private setup; the three selected
* faction decks do not imply the separate Ecaz independent-card option. */
export function createSelectedNexusNoCards(options: SelectedNexusNoCardsOptions) {
const [host, ...others] = options.factions;
let game = withClassicDiscoveryNexusLottery(() => createGame('SELECTEDNEXUSNOCARDS',
newPlayer(host, host, host), options.advanced ?? false, ['ix', 'choam', 'ecaz']));
for (const faction of others) joinGame(game, newPlayer(faction, faction, faction));
const actions: { actor: string; action: Action }[] = [];
const act = (actor: string, action: Action) => {
actions.push({ actor, action: structuredClone(action) });
game = withClassicDiscoveryNexusLottery(() => applyAction(game, actor, action));
};
act(host, { type: 'moduleOption', module: 'nexusCards', enabled: true });
if (options.discovery) act(host, { type: 'moduleOption', module: 'discoveries', enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
act(host, options.advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
const setup = structuredClone(game);
for (let count = 0; game.status === 'setup' && count < 100; count++) {
let next: { actor: string; action: Action } | undefined;
for (const player of game.players) {
const view = viewGame(game, player.id);
view.players.find(seat => seat.id === player.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (action) { next = { actor: player.id, action }; break; }
}
assert.ok(next, `Original setup must expose an owned choice at ${game.setupStage}`);
act(next.actor, next.action);
}
assert.equal(game.status, 'playing');
return { lobby, setup, game, actions };
}
/** Real second-turn worm and closing Nexus. Reorder only undealt singleton
* spice and Nexus cards; all hands and alliances result from player actions. */
export function selectedNexusNoCardsDraw(advanced = false) {
const fixture = createSelectedNexusNoCards({ factions: ['atreides', 'guild', 'harkonnen', 'emperor'], advanced });
let game = structuredClone(fixture.game);
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 = advanceSelectedIndependentNexus(game, state => state.turn === 2 && !!state.nexus &&
!state.spiceWindow && !state.spiceResolution && selectedIndependentNexusClean(state), fixture.actions);
game = actSelectedIndependentNexus(game, 'guild', { type: 'alliance', target: 'harkonnen' }, fixture.actions);
game = actSelectedIndependentNexus(game, 'harkonnen', { type: 'alliance', target: 'guild' }, fixture.actions);
game = advanceSelectedIndependentNexus(game, state => state.nexusCards?.phase?.stage === 'drawing' &&
selectedIndependentNexusClean(state), fixture.actions);
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf('richese');
assert.ok(index >= 0);
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
const beforeDraw = structuredClone(game);
const draw: Action = { type: 'nexusCardChoice', turn: game.turn, card: cards.hands.atreides,
choice: 'draw', ownRedraws: 0 };
game = actSelectedIndependentNexus(game, 'atreides', draw, fixture.actions);
const afterDraw = structuredClone(game);
game = advanceSelectedIndependentNexus(game, state => state.phase === 5 && state.active === 'atreides' &&
selectedIndependentNexusClean(state), fixture.actions);
return { ...fixture, beforeDraw, draw, afterDraw, game };
}
+9 -1
View File
@@ -399,7 +399,15 @@ for (const fuelKind of ['special', 'shield'] as const) void test(
assert.equal(g.lastBattleContext!.winner, choam);
assert.deepEqual(combatArmy(g, choam, 'homeworld:guild'), { normal: 2, elite: 0 });
assert.equal(player(g, choam).tanks, player(before, choam).tanks + 1);
assert.equal(player(g, choam).spice, player(before, choam).spice - 2 + 1 + collectionIncome(g, choam));
// Finishing the battle can also open Collection: CHOAM's sole survivor
// earns Junction's separate occupied-World bank award.
const occupied = g.homeworldOccupiedIncomeHistory!.slice(before.homeworldOccupiedIncomeHistory!.length);
assert.equal(occupied.length, 1);
const junction = occupied[0].receipts.find(receipt => receipt.world === 'homeworld:guild');
assert.ok(junction?.status === 'settled');
assert.deepEqual(junction.credits, [{ player: choam, amount: 2 }]);
assert.equal(player(g, choam).spice, player(before, choam).spice - 2 + 1 +
collectionIncome(g, choam) + junction.credits[0].amount);
for (const token of tokens) assert.equal(g.techTokens![token].owner, choam);
inventory(g);
});
@@ -0,0 +1,102 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { nexusCoreProfile, selectedNexusWithoutCardsProfile,
type FactionModuleProfile } from '../game/faction-module-profile';
import { discoveryModeSupported } from '../game/discovery-module-profile';
import { createSelectedNexusNoCards } from './fixture-selected-nexus-no-cards';
const base: FactionModuleProfile = {
advanced: false,
expansions: ['ix', 'choam', 'ecaz'],
players: [{ faction: 'atreides' }, { faction: 'guild' }, { faction: 'emperor' }],
nexusCards: true,
};
void test('selected Nexus admits independently selected decks with some or all natives absent', () => {
const configurations: FactionModuleProfile[] = [
base,
{ ...base, advanced: true },
{ ...base, expansions: ['ix'], players: [{ faction: 'atreides' }, { faction: 'guild' }] },
{ ...base, expansions: ['choam', 'ecaz'], players: [{ faction: 'richese' }, { faction: 'guild' }] },
{ ...base, players: [{ faction: 'ixians' }, { faction: 'ecaz' }, { faction: 'guild' }] },
{ ...base, players: [{ faction: 'ixians' }, { faction: 'ecaz' }, { faction: 'guild' }],
nexusIxianBetrayalPreview: true, ecazOccupyPreview: true },
{ ...base, advanced: true, players: [{ faction: 'moritani' }, { faction: 'atreides' }],
moritaniAssassinatePreview: true },
{ ...base, advanced: true, discoveryEnabled: true, homeworlds: {}, techTokens: {}, strongholdCards: {} },
];
for (const configuration of configurations) {
assert.equal(selectedNexusWithoutCardsProfile(configuration), true, JSON.stringify(configuration));
assert.equal(nexusCoreProfile(configuration), true, JSON.stringify(configuration));
}
// No missing native family belongs to the existing selected-native paths.
assert.equal(selectedNexusWithoutCardsProfile({ ...base, players: [
{ faction: 'ixians' }, { faction: 'choam' }, { faction: 'ecaz' },
] }), false);
});
void test('selected Nexus rejects unrelated modules and illegal deck or seat configurations', () => {
const rejected = [
{ ...base, nexusCards: undefined },
{ ...base, leaderSkills: {} },
{ ...base, ecazTreachery: true },
{ ...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: 'atreides' }, { faction: 'choam' }] },
{ ...base, players: [{ faction: 'atreides' }, { faction: 'guild' }], techTokens: {} },
{ ...base, strongholdCards: {} },
{ ...base, semutaPreview: true },
{ ...base, kullPreview: true },
{ ...base, nexusKullPreview: true },
{ ...base, nexusIxianBetrayalPreview: true },
{ ...base, moritaniAssassinatePreview: true },
];
for (const configuration of rejected)
assert.equal(selectedNexusWithoutCardsProfile(configuration), false, JSON.stringify(configuration));
});
void test('Discovery requires its explicit enabled selection, even with selected-deck Nexus', () => {
for (const disabled of [undefined, false]) {
const configuration = { ...base, discoveryEnabled: disabled };
assert.equal(selectedNexusWithoutCardsProfile(configuration), true);
assert.equal(discoveryModeSupported(configuration), false);
assert.equal(selectedNexusWithoutCardsProfile({ ...configuration, discoveries: {} }), false);
assert.equal(discoveryModeSupported({ ...configuration, discoveries: {} }), false);
}
const enabled = { ...base, discoveryEnabled: true };
assert.equal(selectedNexusWithoutCardsProfile(enabled), true);
assert.equal(discoveryModeSupported(enabled), true);
assert.equal(discoveryModeSupported({ ...enabled, discoveries: {}, discoveryStash: {}, greatMaker: {} }), true,
'Live Discovery objects do not erase an explicitly selected module');
assert.equal(discoveryModeSupported({ ...enabled, leaderSkills: {} }), false);
});
void test('Advanced Harkonnen with either E3 native stays on its existing excluded boundary', () => {
for (const faction of ['ecaz', 'moritani']) {
const players = [{ faction: 'harkonnen' }, { faction }];
const basic = { ...base, players, advanced: false };
const advanced = { ...base, players, advanced: true };
assert.equal(selectedNexusWithoutCardsProfile(basic), true, faction);
assert.equal(selectedNexusWithoutCardsProfile(advanced), false, faction);
assert.equal(selectedNexusWithoutCardsProfile({ ...advanced, discoveryEnabled: true }), false, faction);
assert.equal(discoveryModeSupported({ ...advanced, discoveryEnabled: true }), false, faction);
assert.equal(selectedNexusWithoutCardsProfile({ ...basic, leaderSkills: {} }), false, faction);
}
assert.equal(selectedNexusWithoutCardsProfile({ ...base, advanced: true,
players: [{ faction: 'harkonnen' }, { faction: 'ixians' }] }), true,
'Selecting the unused E3 deck alone does not create an E3 native capture');
});
void test('public Advanced selected Nexus initializes native Moritani assassination with unused Ix and CHOAM', () => {
const { setup, game } = createSelectedNexusNoCards({ advanced: true,
factions: ['moritani', 'atreides', 'guild'] });
assert.equal(selectedNexusWithoutCardsProfile(setup), true);
assert.equal(selectedNexusWithoutCardsProfile(game), true);
assert.equal(game.status, 'playing');
assert.ok(game.moritaniAssassinate, 'The public start initializes the native Advanced faction state');
assert.ok(game.nexusCards?.cards);
});
@@ -0,0 +1,65 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import { TERRITORIES } from '../game/board';
import { treacheryDeck } from '../game/cards';
import { nexusCoreProfile, selectedNexusWithoutCardsProfile } from '../game/faction-module-profile';
import { validateNexusCards } from '../game/nexus-cards';
import { validateDiscoveryState } from '../game/discoveries';
import { advanceSelectedIndependentNexus } from './fixture-selected-independent-nexus-cards';
import { createSelectedNexusNoCards, selectedNexusNoCardsDraw,
type SelectedNexusNoCardsOptions } from './fixture-selected-nexus-no-cards';
const setups: SelectedNexusNoCardsOptions[] = [
{ factions: ['atreides', 'guild', 'emperor'] as const },
{ factions: ['atreides', 'guild', 'emperor'] as const, advanced: true },
{ factions: ['ixians', 'ecaz', 'guild'] as const },
{ factions: ['ixians', 'ecaz', 'guild'] as const, advanced: true, discovery: true },
];
for (const options of setups) void test(`public ${options.advanced ? 'Advanced' : 'Basic'} selected Nexus without independent cards: ${options.factions.join('/')}${options.discovery ? ' Discovery' : ''}`, () => {
const { lobby, setup, game, actions } = createSelectedNexusNoCards(options);
assert.ok(actions.some(step => step.action.type === 'moduleOption' && step.action.module === 'nexusCards'));
assert.ok(!actions.some(step => step.action.type === 'moduleOption' && step.action.module === 'ecazTreachery'));
assert.notEqual(lobby.lobbyOptions?.ecazTreachery, true);
assert.ok(selectedNexusWithoutCardsProfile(setup));
assert.ok(selectedNexusWithoutCardsProfile(game));
assert.ok(nexusCoreProfile(game));
assert.notEqual(game.ecazTreachery, true);
assert.equal(game.leaderSkills, undefined);
assert.deepEqual(game.expansions, ['ix', 'choam', 'ecaz']);
assert.equal(game.phase, 0);
validateNexusCards(game.nexusCards!.cards!, game.players);
assert.equal(game.nexusCards!.cards!.deck.length, 12);
assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card => card === null));
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 (options.discovery) { assert.ok(game.discoveries); validateDiscoveryState(game.discoveries); }
if (options.factions.includes('ixians')) assert.equal(game.nexusIxianBetrayalPreview, true);
if (options.factions.includes('ecaz')) assert.equal(game.ecazOccupyPreview, true);
});
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} selected decks deal a real Nexus card and spend its shipment power`, () => {
const fixture = selectedNexusNoCardsDraw(advanced);
const { beforeDraw, afterDraw } = fixture;
assert.equal(beforeDraw.nexusCards!.phase!.stage, 'drawing');
assert.equal(beforeDraw.nexusCards!.cards!.hands.atreides, null);
const botView = viewGame(beforeDraw, 'atreides');
botView.players.find(player => player.id === 'atreides')!.bot = 'Easy';
const botChoice = botActions(botView).find(action => action.type === 'nexusCardChoice');
assert.ok(botChoice, 'The real eligible player has a legal bot choice');
assert.doesNotThrow(() => applyAction(beforeDraw, 'atreides', botChoice));
assert.equal(afterDraw.nexusCards!.cards!.hands.atreides, 'richese');
const offer = viewGame(fixture.game, 'atreides').nexusRichese;
assert.ok(offer && !offer.blocked);
const place = TERRITORIES.find(territory => territory.type === 'stronghold' &&
!territory.sectors.includes(fixture.game.storm) && fixture.game.players.every(player =>
Object.keys(player.forces).every(key => !key.startsWith(`${territory.id}:`))));
assert.ok(place);
let game = applyAction(fixture.game, 'atreides', { type: 'ship', territory: place.id,
sector: place.sectors[0], amount: 1, elite: 0, allyPayment: 0, nexus: offer.event });
game = advanceSelectedIndependentNexus(game, state => state.nexusRicheseLast?.stage === 'shipped');
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);
});
+71
View File
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { spiceDeck, treacheryDeck } from '../game/cards';
import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_SPICE_CARDS, validateDiscoveryState } from '../game/discoveries';
import { actBasicSemutaAmbassador as act, basicSemutaAmbassadorClean } from './fixture-basic-semuta-ambassador';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
function advance(game: Game, until: (state: Game) => boolean): Game {
for (let count = 0; count < 1600; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextRicheseStrongholdsNativeStep(game);
assert.ok(step, `No native continuation at ${game.status}/${game.turn}/${game.phase}`);
game = act(game, step.actor, step.action);
}
throw Error('The original public game did not reach the requested boundary');
}
for (const advanced of [false, true]) void test(
`${advanced ? 'Advanced' : 'Basic'} classic seats with only E3 selected resolve an original Discovery Spice Blow`, () => {
let game = createGame('UNUSEDE3DISCOVERY', newPlayer('guild', 'Guild', 'guild'), advanced, ['ecaz']);
for (const faction of ['atreides', 'emperor'] as const)
joinGame(game, newPlayer(faction, faction, faction));
game = act(game, 'guild', {type:'moduleOption', module:'discoveries', enabled:true});
for (const seat of game.players) game = act(game, seat.id, {type:'ready'});
assert.equal(game.discoveryEnabled, true);
game = act(game, 'guild', {type:'start', ...(advanced ? {advancedPreview:true} : {})});
assert.equal(game.status, 'setup');
assert.equal(game.advanced, advanced);
assert.deepEqual(game.expansions, ['ecaz']);
assert.deepEqual(game.players.map(p => p.faction).sort(), ['atreides', 'emperor', 'guild']);
assert.ok(game.discoveries);
assert.equal(game.discoveries.tokens.length, 8);
assert.deepEqual(game.deck.map(c => c.id).sort(), treacheryDeck(['ecaz']).map(c => c.id).sort());
assert.equal(game.spiceDeck.length, spiceDeck().length + 7);
assert.deepEqual(game.spiceDeck.flatMap(c => 'territory' in c && c.discovery ? [c.discovery] : []).sort(),
DISCOVERY_SPICE_CARDS.map(c => c.discovery).sort());
assert.ok(!game.nexusCards && !game.ecazTreachery && !game.leaderSkills);
assert.ok(!game.ecazAmbassadors && !game.moritaniTerror);
validateDiscoveryState(game.discoveries);
game = advance(game, state => state.status === 'playing');
// Reorder original unopened cards only; all draws, token selection and phase
// transitions still pass through the native engine.
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 && basicSemutaAmbassadorClean(state));
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!);
assert.ok(!game.nexusCards && !game.ecazTreachery);
assert.ok(game.active);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, game.active);
view.players.find(p => p.id === game.active)!.bot = difficulty;
const action = botActions(view)[0];
assert.ok(action, difficulty);
assert.doesNotThrow(() => act(game, game.active!, action), difficulty);
}
});