Support unused selected decks with Nexus and Discovery
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
c8dbd44975
commit
91d45d1dec
14 files changed
+405
-17
No files matched your search
@@ -17,6 +17,16 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Selected decks with Nexus or Discovery without extra cards — 29 October.
|
||||
Prototyped / Partial.** Basic and Advanced Nexus previews admit selected but
|
||||
unseated families without independent Ecaz cards, optionally with Discovery;
|
||||
Discovery also admits a lone E3 deck with classic seats. Public-start fixtures
|
||||
exercise an actual worm, Nexus draw, card-powered shipment and Discovery blow.
|
||||
Fifteen related files pass 118 cases. Leader Skills, Advanced Harkonnen with
|
||||
Ecaz or Moritani in the new Nexus profile, deeper interactions, complete games
|
||||
and deployed verification remain open. [Nexus scope](NEXUS_CARD_RULES.md#selected-decks-without-independent-cards--29-october-2026);
|
||||
[Discovery scope](DISCOVERY_PROTOTYPE.md#selected-but-unused-e3-deck--29-october-2026).
|
||||
|
||||
**Mixed selected-deck Discovery without extra variants — 29 October.
|
||||
Prototyped / Partial.** Basic and Advanced previews can select Discovery with
|
||||
E3 plus Ix and/or CHOAM without also selecting Nexus or independent cards.
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# Discovery functional prototype
|
||||
|
||||
## Selected but unused E3 deck — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** A fresh Basic or Advanced preview can select only
|
||||
the E3 faction deck with Discovery while seating classic factions alone, without
|
||||
Nexus or independent Ecaz cards. The same original seven Spice Cards and eight
|
||||
tokens are initialized. Public-start fixtures reach an actual Discovery Spice
|
||||
Blow with controlled unopened card order and legal bot choices. Optional
|
||||
interactions, complete games and deployed verification remain open.
|
||||
|
||||
## Mixed selected-deck Discovery alone — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** Basic and Advanced previews can use Discovery with
|
||||
|
||||
@@ -2,6 +2,20 @@
|
||||
|
||||
Original source audit, 10 September 2026, with dated bounded runtime follow-ups below. This document establishes the printed component inventory, source protocol and implementation boundaries; none lifts expansion release gates. The ordinary worm-triggered alliance Nexus already in the engine is distinct from this optional card module.
|
||||
|
||||
## Selected decks without independent cards — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** Fresh Basic and Advanced Nexus previews can select
|
||||
original expansion decks without the independent Ecaz cards when at least one
|
||||
selected family has no seated native faction. Discovery may also be selected.
|
||||
Existing seat and module prerequisites remain; Leader Skills and Advanced
|
||||
Harkonnen with Ecaz or Moritani retain their separate boundaries. Ecaz Occupy,
|
||||
Moritani assassination and their shared arrival use their existing handlers.
|
||||
|
||||
Public-start fixtures cover classic and native seats, original selected card
|
||||
stock, a real worm-triggered alliance and Nexus draw, and a Richese card spent
|
||||
on shipment. They control unopened deck order. These cases do not establish
|
||||
complete games or all card and module interactions; deployed proof remains open.
|
||||
|
||||
## Independent cards with Discovery — 18 October 2026
|
||||
|
||||
**Prototyped / Partial.** The independent three-card variant now composes with
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# Rule decision index
|
||||
|
||||
## Selected decks with Nexus or Discovery — 29 October 2026
|
||||
|
||||
Selecting an expansion deck does not require seating its faction or enabling
|
||||
independent Ecaz cards. The new Nexus and Discovery previews retain existing
|
||||
module and unresolved interaction boundaries. [Nexus scope](NEXUS_CARD_RULES.md#selected-decks-without-independent-cards--29-october-2026);
|
||||
[Discovery scope](DISCOVERY_PROTOTYPE.md#selected-but-unused-e3-deck--29-october-2026).
|
||||
|
||||
## Mixed selected-deck Discovery alone — 29 October 2026
|
||||
|
||||
Discovery no longer requires Nexus or independent cards when E3 is selected
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FACTIONS } from './catalog';
|
||||
import { independentDiscoveryProfile, mixedE3NexusRosterProfile, selectedPhaseModuleRoster, type FactionModuleProfile } from './faction-module-profile';
|
||||
import { independentDiscoveryProfile, mixedE3NexusRosterProfile, selectedNexusWithoutCardsProfile, selectedPhaseModuleRoster, type FactionModuleProfile } from './faction-module-profile';
|
||||
import type { LeaderSkillProfile } from './leader-skill-profile';
|
||||
|
||||
/** Public unused-lobby admission only. The original initializer owns physical
|
||||
@@ -17,7 +17,7 @@ export function homeworldDiscoveryProfile(game: FactionModuleProfile): boolean {
|
||||
/** The same roster/module contract during play; live Discovery frames are expected. */
|
||||
export function discoveryModeSupported(game: FactionModuleProfile): boolean {
|
||||
if (independentDiscoveryProfile(game)) return true;
|
||||
if (game.nexusCards) return standaloneE3DiscoveryNexusProfile(game) || singleE1E2DiscoveryNexusProfile(game) ||
|
||||
if (game.nexusCards) return (game.discoveryEnabled === true && selectedNexusWithoutCardsProfile(game)) || standaloneE3DiscoveryNexusProfile(game) || singleE1E2DiscoveryNexusProfile(game) ||
|
||||
mixedE3DiscoveryNexusProfile(game) || mixedE1E2DiscoveryNexusProfile(game) || pairedE3DiscoveryNexusProfile(game);
|
||||
if (typeof game.advanced !== 'boolean' || game.discoveryEnabled !== true ||
|
||||
game.players.length < 2 || game.players.length > 6 ||
|
||||
@@ -41,7 +41,7 @@ export function discoveryModeSupported(game: FactionModuleProfile): boolean {
|
||||
if (e3 ? expansion !== 'ecaz' : expansion !== 'ix' && expansion !== 'choam') return false;
|
||||
for (let j = 0; j < i; j++) if (game.expansions[j] === expansion) return false;
|
||||
}
|
||||
let native = 0, moritani = false, harkonnen = false;
|
||||
let moritani = false, harkonnen = false;
|
||||
for (let i = 0; i < game.players.length; i++) {
|
||||
const player = game.players[i];
|
||||
for (let j = 0; j < i; j++) if (game.players[j].faction === player.faction) return false;
|
||||
@@ -52,12 +52,11 @@ export function discoveryModeSupported(game: FactionModuleProfile): boolean {
|
||||
if (expansion !== 'base') {
|
||||
if (e3 ? player.faction !== 'ecaz' && player.faction !== 'moritani'
|
||||
: !game.expansions.includes(expansion)) return false;
|
||||
native++;
|
||||
}
|
||||
if (player.faction === 'moritani') moritani = true;
|
||||
if (player.faction === 'harkonnen') harkonnen = true;
|
||||
}
|
||||
return (!e3 || native >= 1 && native <= 2) && !(game.advanced && moritani && harkonnen);
|
||||
return !(game.advanced && moritani && harkonnen);
|
||||
}
|
||||
|
||||
/** The original E3 pair can use selected decks with Discovery without Nexus or cards. */
|
||||
|
||||
+9
-8
@@ -99,7 +99,7 @@ import { movementShipmentPreparations } from './movement-shipment-preparation';
|
||||
import { MovementClaimError, liveMovementPromises, movementEventAllowed, movementPromiseModeSupported, movementPromiseTargetAvailable, settleMovementEvent, finishMovementPromises, validMovementClaim, type MovementClaim, type MovementPromise } from './movement-promises';
|
||||
import { findMovementCompletion, type MovementCompletion } from './movement-promise-search';
|
||||
import { classicNexusModulesProfile, mixedE1E2NexusProfile, mixedE3NexusProfile, pairedE3NexusProfile, pairedNexusModulesProfile, singleE1E2NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
|
||||
import { independentDiscoveryProfile, independentNexusProfile, nexusCoreProfile, nativeFactionTechProfile, selectedFactionStrongholdProfile, selectedIndependentCardsProfile, selectedPhaseModuleRoster } from './faction-module-profile';
|
||||
import { independentDiscoveryProfile, independentNexusProfile, nexusCoreProfile, nativeFactionTechProfile, selectedFactionStrongholdProfile, selectedIndependentCardsProfile, selectedNexusWithoutCardsProfile, selectedPhaseModuleRoster } from './faction-module-profile';
|
||||
import { homeworldOccupationLeaderSkillsProfile, homeworldOccupationModulesProfile, homeworldPhaseModulesProfile } from './homeworld-module-profile';
|
||||
import { RevivalPromiseError, revivalPromiseModeSupported, liveRevivalPromises, recordPromisedRevival,
|
||||
revivalPromisesSatisfied, validateRevivalPromises, type RevivalClaim, type RevivalPromise } from './revival-promises';
|
||||
@@ -11382,7 +11382,7 @@ function e3StrongholdFactionProfile(g: Game): boolean {
|
||||
return native !== null && (native !== 'moritani' || !byFaction(g, 'harkonnen'));
|
||||
}
|
||||
function moritaniAssassinateModeSupported(g: Game) {
|
||||
if (independentNexusProfile(g) || independentDiscoveryProfile(g)) return g.advanced && !!byFaction(g, 'moritani');
|
||||
if (independentNexusProfile(g) || independentDiscoveryProfile(g) || selectedNexusWithoutCardsProfile(g)) return g.advanced && !!byFaction(g, 'moritani');
|
||||
if (mixedE3NexusProfile(g) || mixedE3NexusLeaderSkillsProfile(g) ||
|
||||
standaloneE3NexusProfile(g) || pairedE3NexusProfile(g) || pairedE3NexusLeaderSkillsProfile(g))
|
||||
return g.advanced && g.players.some(player => player.faction === 'moritani');
|
||||
@@ -12477,10 +12477,11 @@ export function initializeNexusGameForAudit(state: Game): Game {
|
||||
const standalone = standaloneE3NexusProfile(state);
|
||||
const pairedE3 = pairedE3NexusProfile(state);
|
||||
const mixedE3 = mixedE3NexusProfile(state);
|
||||
const native = mixedE3 || standalone || pairedE3 || singleE1E2NexusProfile(state) || mixedE1E2NexusProfile(state);
|
||||
const selected = selectedNexusWithoutCardsProfile(state);
|
||||
const native = selected || mixedE3 || standalone || pairedE3 || singleE1E2NexusProfile(state) || mixedE1E2NexusProfile(state);
|
||||
const discovery = classicDiscoveryNexusProfile(state) || standaloneE3DiscoveryNexusProfile(state) ||
|
||||
mixedE3DiscoveryNexusProfile(state) || singleE1E2DiscoveryNexusProfile(state) ||
|
||||
mixedE1E2DiscoveryNexusProfile(state) || pairedE3DiscoveryNexusProfile(state);
|
||||
mixedE1E2DiscoveryNexusProfile(state) || pairedE3DiscoveryNexusProfile(state) || (selected && !!state.discoveryEnabled);
|
||||
if (state.discoveryEnabled) {
|
||||
requireRule(discovery,
|
||||
'Discovery Nexus requires a fresh supported original classic or native roster and module envelope.');
|
||||
@@ -12488,8 +12489,8 @@ export function initializeNexusGameForAudit(state: Game): Game {
|
||||
requireFreshFactionInventory(state);
|
||||
}
|
||||
const g = initializeSetupGameForAudit(state, !!state.homeworlds, true, false, discovery, false, false, native);
|
||||
if ((mixedE3 || standalone || pairedE3) && byFaction(g, 'ecaz')) g.ecazOccupyPreview = true;
|
||||
if ((mixedE3 || standalone || pairedE3) && g.advanced && byFaction(g, 'moritani')) initializeMoritaniAssassinateState(g);
|
||||
if ((selected || mixedE3 || standalone || pairedE3) && byFaction(g, 'ecaz')) g.ecazOccupyPreview = true;
|
||||
if ((selected || mixedE3 || standalone || pairedE3) && g.advanced && byFaction(g, 'moritani')) initializeMoritaniAssassinateState(g);
|
||||
return g;
|
||||
}
|
||||
/** Narrow original standalone admission; normal classics share fresh effect state. */
|
||||
@@ -13452,7 +13453,7 @@ function ecazOccupyCompositionSupported(g: Game): boolean {
|
||||
const basicNatives = g.advanced === false && !g.strongholdCards &&
|
||||
((!g.ecazTreachery && (!g.techTokens || nativeFactionTechProfile(g))) || selectedIndependentCardsProfile(g) ||
|
||||
g.kullPreview === true && selectedIndependentCardsProfile({...g,kullPreview:undefined}));
|
||||
if ((independentNexusProfile(g) || independentDiscoveryProfile(g)) && byFaction(g, 'ecaz')) return true;
|
||||
if ((independentNexusProfile(g) || independentDiscoveryProfile(g) || selectedNexusWithoutCardsProfile(g)) && byFaction(g, 'ecaz')) return true;
|
||||
if (g.leaderSkills && nativeEcazLeaderSkillsProfile(g)) return true;
|
||||
if (g.ecazOccupyPreview === true && (mixedE3NexusProfile(g) || standaloneE3NexusProfile(g) || pairedE3NexusProfile(g)) &&
|
||||
g.players.some(player => player.faction === 'ecaz')) return true;
|
||||
@@ -25170,7 +25171,7 @@ function arrivalOverlapModeSupported(g: Game, revivalArrival?: RevivalEntryParen
|
||||
if (homeworldPhaseModulesProfile(g) && byFaction(g, 'ecaz') && byFaction(g, 'moritani')) return true;
|
||||
if (revivalArrival && g.homeworlds && byFaction(g, 'ecaz') && byFaction(g, 'moritani')) return true;
|
||||
if (bareBasicSemutaProfile(g) && byFaction(g, 'ecaz') && byFaction(g, 'moritani')) return true;
|
||||
if ((independentNexusProfile(g) || independentDiscoveryProfile(g)) && byFaction(g, 'ecaz') && byFaction(g, 'moritani')) return true;
|
||||
if ((independentNexusProfile(g) || independentDiscoveryProfile(g) || selectedNexusWithoutCardsProfile(g)) && byFaction(g, 'ecaz') && byFaction(g, 'moritani')) return true;
|
||||
if ((mixedE3NexusLeaderSkillsProfile(g) || mixedE3NexusProfile(g)) &&
|
||||
byFaction(g, 'ecaz') && byFaction(g, 'moritani')) return true;
|
||||
if (g.ecazTreachery && byFaction(g, 'ecaz') && byFaction(g, 'moritani') &&
|
||||
|
||||
@@ -59,10 +59,23 @@ export function independentDiscoveryNexusProfile(game: NexusCoreConfiguration):
|
||||
return !!game.nexusCards && independentDiscoveryProfile(game);
|
||||
}
|
||||
|
||||
/** Common consumers of the classic and selected-independent fresh Nexus core. */
|
||||
/** Original selected decks do not require their native factions or the
|
||||
* independent three-card variant. Keep Skills and hidden E3/Harkonnen
|
||||
* compositions on their existing separately scoped paths. */
|
||||
export function selectedNexusWithoutCardsProfile(game: NexusCoreConfiguration): boolean {
|
||||
const players = game.players;
|
||||
if (game.ecazTreachery || game.leaderSkills || !game.nexusCards || !players ||
|
||||
!game.expansions.length || !nexusCoreRosterProfile(game, true, game.discoveryEnabled === true)) return false;
|
||||
if (game.advanced && players.some(player => player.faction === 'harkonnen') &&
|
||||
players.some(player => player.faction === 'ecaz' || player.faction === 'moritani')) return false;
|
||||
return game.expansions.some(expansion => !players.some(player =>
|
||||
FACTIONS.some(faction => faction.id === player.faction && faction.expansion === expansion)));
|
||||
}
|
||||
|
||||
/** Common consumers of supported classic and selected-deck Nexus games. */
|
||||
export function nexusCoreProfile(game: NexusCoreConfiguration): boolean {
|
||||
return classicNexusCoreProfile(game) || selectedIndependentNexusProfile(game) ||
|
||||
independentDiscoveryNexusProfile(game);
|
||||
independentDiscoveryNexusProfile(game) || selectedNexusWithoutCardsProfile(game);
|
||||
}
|
||||
|
||||
function nexusCoreRosterProfile(game: NexusCoreConfiguration, selectedDecks: boolean, discovery = false): boolean {
|
||||
|
||||
+2
-1
@@ -5925,7 +5925,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
summary: 'A development preview connects Great Maker, six Discovery spice blows, private token inspection, stash rewards, revealed locations, next-turn entry, Cistern income, bounded Orgiz transfers, Ornithopter movement, Jacurutu income, Testing Station and Shrine. Supported public starts remain previews; remaining interpretations and complete-mode acceptance stay open.',
|
||||
searchText: 'Hiereg Smuggler Jacurutu Sietch Cistern Ecological Testing Station Shrine Orgiz Processing Station Treachery Card Stash Spice Stash Ornithopter',
|
||||
steps: [
|
||||
'The Discovery-only fresh preview supports classics and selected Ixian/Tleilaxu, CHOAM/Richese and Ecaz/Moritani decks in Basic/Advanced2–6, including mixed selections with unused decks. It supplies seven extra Spice Cards/eight tokens; optional Tech requires3+ and Strongholds require Advanced. Nexus and independent cards are not required. Existing Advanced Moritani/Harkonnen and deeper interaction restrictions remain; complete-mode acceptance stays separate.',
|
||||
'The Discovery-only fresh preview supports classics and selected Ixian/Tleilaxu, CHOAM/Richese and Ecaz/Moritani decks in Basic/Advanced2–6, including unused selected decks. It supplies seven extra Spice Cards/eight tokens; optional Tech requires3+ and Strongholds require Advanced. Nexus and independent cards are not required. Existing Advanced Moritani/Harkonnen and deeper interaction restrictions remain; complete-mode acceptance stays separate.',
|
||||
'Fresh Discovery retains original Homeworlds and native reserves in classic and supported E1/E2 or E3 tables, Basic/Advanced2–6, optional Tech3+/Advanced Strongholds. Original Nexus entries additionally admit natives from BOTH Ix+CHOAM families/union47 or BOTH Ecaz+Moritani/ecaz33 with classics, with or without all14 Skills. Maker, actual parent/nested entry, source-local payments/rescue, phase-end industry and original-winner cleanup/Tech remain. Held benefits do not migrate into nested locations. Original capture/roster and unresolved-effect guards remain.',
|
||||
'The original leader-skills --nexus-cards --discoveries entry composes all14 offered training/all12 Nexus/Discovery7/8 for classics/base33, single or paired one-family E1/E2, natives from BOTH selected Ix+CHOAM families/union47, or standalone OR paired Ecaz/Moritani/ecaz33 with classics. Original Homeworlds may be retained; Tech requires3+ and Strongholds Advanced. Hands/offers, native components and actual Maker/both-pile/alliance-change closing draws remain authoritative. Shared Duke never trains; Advanced paired E3/Moritani excludes Harkonnen. Mixed E3/three-family and pending effects/public starts/save conversion remain separate.',
|
||||
'Connected native outcomes use original sources: Suboid Cunning and winning trained Suk settle typed fixed casualties before one equal actual Cyborg Tank substitution; original cleanup and mandatory winner Tech precede matching Face Dance, retaining its foreign-Homeworld suppression. Signed Richese two-marker one-invoice physical shipment requires a currently revealed nested site. Ecaz-selected coalition armies and Moritani skill-first assassination remain distinct from shared-Duke training. These programmes do not certify every skill or card band.',
|
||||
@@ -6294,6 +6294,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Entering any alliance discards your held Nexus Card, including alliances made through an Ambassador or Terror token. An offer that has not been accepted does not discard it.',
|
||||
'Use Cunning for your own faction, Betrayal when another player controls the printed faction, and Secret Ally when that faction is absent. Used cards go to the separate Nexus discard pile. Each printed effect has its own timing.',
|
||||
'Fresh original Nexus tables may retain Homeworlds, Discovery7/8, Tech3+ Basic/Advanced and Strongholds2+ Advanced, optionally all14 through leader-skills --nexus-cards. Original roster/deck envelopes include classics/base33, selected E1/E2 natives/union47 and standalone, paired or mixed E3 native families. Printed/native setup, first-Storm Tech, actual end-Mentat claims and all12 closing draws remain. Original no-Skills E3 Nexus now admits Advanced Harkonnen; Skills retains its Harkonnen exclusion and shared Duke never trains. Public starts, played-save conversion and pending effects remain separate.',
|
||||
'Fresh selected-deck Nexus previews also admit an unseated selected family without independent Ecaz cards, with optional Discovery. Leader Skills and Advanced Harkonnen with Ecaz or Moritani retain their separate restrictions; wider interactions and complete-mode acceptance remain open.',
|
||||
'Fresh nexus --discoveries uses the original no-Skills envelopes above with optional Homeworlds, Tech3+ and Advanced Strongholds. Seven Discovery Spice Cards/eight tokens, protected nested sites, next-turn physical entry and actual Maker losses/votes/reserve ride remain. The card draw follows both Advanced piles and actual settled alliance change; earlier Shai-Hulud losses never repeat. Source-clear quotas, borrowed Guild tariffs and signed native one-invoice pairs at revealed sites use original controls, not new transport adapters.',
|
||||
'Without Skills, the existing generic Nexus initializer handles single and mixed selected E1/E2 natives, while paired one-family and paired E3 entries use their original paired initializer and require BOTH original natives. Native HMS/Face Dancers/Auditor/Richese cache, Ecaz ordinary discs/Loyalty/shared Duke and Advanced Moritani assassination remain physical. New overlays do not create a first Nexus hand, free native return, arbitrary marker companion or played-game conversion.',
|
||||
'The separate fresh local leader-skills --nexus-cards entry combines classic base33/all14 Skills/all12 Nexus in Basic or Advanced, two through six seats, optionally adding original Discovery7/8 through --discoveries. Original Tech requires three or more seats; Stronghold Cards require Advanced. Starting hands/offers, actual end-Spice closing draws, first-Storm Tech and end-Mentat held custody remain. Extra/ordinary free-three returns earn original phase-end Axlotl without changing skill custody. Emperor Cunning composes skill posture, support and Suk rescue; Arrakeen reduces payer cost, not committed support or rescue eligibility. Winner cleanup precedes mandatory Tech reward. Unrelated overlays and public starts remain guarded.',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user