Support selected Nexus decks with leader skills
Verify and publish checkpoint container / container (push) Successful in 11m54s
Verify and publish checkpoint container / container (push) Successful in 11m54s
This commit is contained in:
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);
|
||||
});
|
||||
Reference in new issue
Block a user