Support selected Nexus decks with leader skills
Verify and publish checkpoint container / container (push) Successful in 11m54s

This commit is contained in:
Quintine committed 2026-10-29 20:26:46 +11:00
1 parent 91d45d1dec
commit 00e01a7cd2
13 files changed
+314 -23

No files matched your search

@@ -113,9 +113,24 @@ for (const advanced of [false, true]) {
sameSkillCustody(f.game, returned);
for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
const short = createClassicNexusSkillsPaymentsFixture({ advanced, kind, tanks: 2 });
rejected(short.game, short.actor, short.paymentAction);
assert.equal(classicNexusSkillsPaymentsPlayer(short.game, short.actor).tanks, 2);
classicNexusSkillsPaymentsInventory(short.game);
const before = classicNexusSkillsPaymentsPlayer(short.game, short.actor);
if (kind === 'fremen-revival') {
// Atreides already revives both remaining counters for free, so the
// Fremen quota cannot improve this particular two-counter position.
rejected(short.game, short.actor, short.paymentAction);
assert.equal(before.tanks, 2);
classicNexusSkillsPaymentsInventory(short.game);
continue;
}
let granted = applyAction(short.game, short.actor, short.paymentAction);
granted = finishClassicNexusSkillsPaymentsFixture(short, granted);
const after = classicNexusSkillsPaymentsPlayer(granted, short.actor);
assert.equal(after.tanks, 0);
assert.equal(after.reserves, before.reserves + 2);
assert.equal(after.spice, before.spice);
assert.equal(after.revived, before.revived);
assert.equal(after.freeForcesRevived ?? 0, before.freeForcesRevived ?? 0);
classicNexusSkillsPaymentsInventory(granted);
}
const extra = createClassicNexusSkillsPaymentsFixture({ advanced, kind: 'emperor-revival' });
let ordinary = finishClassicNexusSkillsPaymentsFixture(extra,
@@ -316,6 +316,10 @@ export function createClassicNexusSkillsPaymentsFixture(options: ClassicNexusSki
paymentAction = { type: 'nexusTraitorDraw', event: offer.event, mode: offer.mode };
} else {
game = advanceClassicNexusSkillsPaymentsToPhase(game, 4, actions);
// Advanced Revival now follows the actual player order. Complete earlier
// seats' opportunities before the actor uses its ordinary revival quota.
game = advanceClassicNexusSkillsPayments(game, state => state.phase === 4 && clean(state) &&
(!state.revivalOrder || state.revivalOrder.remaining[0] === actor), actions);
const player = classicNexusSkillsPaymentsPlayer(game, actor);
const tanks = options.tanks ?? 6, elite = options.eliteTanks ?? 0;
assert.ok(Number.isInteger(tanks) && tanks >= 0 && elite >= 0 && elite <= tanks);
@@ -38,7 +38,6 @@ void test('selected Nexus admits independently selected decks with some or all n
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'] },
@@ -72,7 +71,7 @@ void test('Discovery requires its explicit enabled selection, even with selected
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);
assert.equal(discoveryModeSupported({ ...enabled, leaderSkills: {} }), true);
});
void test('Advanced Harkonnen with either E3 native stays on its existing excluded boundary', () => {
@@ -84,7 +83,7 @@ void test('Advanced Harkonnen with either E3 native stays on its existing exclud
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({ ...basic, leaderSkills: {} }), true, faction);
}
assert.equal(selectedNexusWithoutCardsProfile({ ...base, advanced: true,
players: [{ faction: 'harkonnen' }, { faction: 'ixians' }] }), true,
@@ -0,0 +1,92 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { bureaucratPaymentModeSupported } from '../game/bureaucrat-payment';
import { discoveryModeSupported } from '../game/discovery-module-profile';
import { nexusCoreProfile, selectedNexusWithoutCardsProfile, type FactionModuleProfile } from '../game/faction-module-profile';
import { nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile,
ordinaryLeaderSkillModeSupported, selectedNexusLeaderSkillsProfile } from '../game/leader-skill-profile';
const base: FactionModuleProfile = {
advanced: false,
expansions: ['ix', 'choam', 'ecaz'],
players: [{ faction: 'atreides' }, { faction: 'guild' }, { faction: 'emperor' }],
nexusCards: {},
leaderSkills: {},
};
void test('selected Nexus Skills reuse ordinary Skills, Bureaucrat and Nexus rules with unused families', () => {
const rows: FactionModuleProfile[] = [
base,
{ ...base, advanced: true },
{ ...base, players: [{ faction: 'ixians' }, { faction: 'ecaz' }, { faction: 'guild' }],
nexusIxianBetrayalPreview: true, 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' }],
nexusIxianBetrayalPreview: true },
];
for (const row of rows) {
const label = JSON.stringify(row);
assert.equal(selectedNexusWithoutCardsProfile(row), true, label);
assert.equal(nexusCoreProfile(row), true, label);
assert.equal(selectedNexusLeaderSkillsProfile(row), true, label);
assert.equal(nativeExpansionLeaderSkillsProfile(row), true, label);
assert.equal(ordinaryLeaderSkillModeSupported(row), true, label);
assert.equal(bureaucratPaymentModeSupported(row), true, label);
}
});
void test('selected Nexus Skills Discovery requires selection and survives live original token frames', () => {
assert.equal(discoveryModeSupported(base), false);
assert.equal(nativeDiscoveryLeaderSkillsProfile(base), false);
const enabled: FactionModuleProfile = { ...base, discoveryEnabled: true };
for (const row of [enabled, { ...enabled, discoveries: {}, discoveryStash: {}, greatMaker: {} }]) {
assert.equal(selectedNexusLeaderSkillsProfile(row), true);
assert.equal(discoveryModeSupported(row), true);
assert.equal(nativeDiscoveryLeaderSkillsProfile(row), true);
assert.equal(ordinaryLeaderSkillModeSupported(row), true);
assert.equal(bureaucratPaymentModeSupported(row), true);
}
assert.equal(selectedNexusWithoutCardsProfile({ ...base, discoveries: {} }), false);
assert.equal(discoveryModeSupported({ ...base, discoveries: {} }), false);
});
void test('selected Nexus Skills respect physical seat, family, optional-module and stamp boundaries', () => {
const rejected: FactionModuleProfile[] = [
{ ...base, nexusCards: undefined },
{ ...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, ecazTreachery: true },
{ ...base, semutaPreview: true },
{ ...base, kullPreview: true },
{ ...base, nexusKullPreview: true },
{ ...base, nexusIxianBetrayalPreview: true },
{ ...base, moritaniAssassinatePreview: true },
];
for (const row of rejected) {
const label = JSON.stringify(row);
assert.equal(selectedNexusWithoutCardsProfile(row), false, label);
assert.equal(selectedNexusLeaderSkillsProfile(row), false, label);
}
});
void test('Advanced Harkonnen with an actual E3 native remains excluded, but unused E3 is permitted', () => {
for (const faction of ['ecaz', 'moritani']) {
const players = [{ faction: 'harkonnen' }, { faction }];
const basic = { ...base, players }, advanced = { ...basic, advanced: true };
assert.equal(selectedNexusLeaderSkillsProfile(basic), true, faction);
assert.equal(selectedNexusLeaderSkillsProfile(advanced), false, faction);
assert.equal(discoveryModeSupported({ ...advanced, discoveryEnabled: true }), false, faction);
}
const noE3Seat = { ...base, advanced: true, players: [{ faction: 'harkonnen' }, { faction: 'ixians' }] };
assert.equal(selectedNexusLeaderSkillsProfile(noE3Seat), true);
assert.equal(bureaucratPaymentModeSupported(noE3Seat), true);
});
@@ -0,0 +1,145 @@
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);
});