Support mixed Discovery decks with Leader Skills
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
7c1f4b4e09
commit
77eb2392eb
15 files changed
+420
-11
No files matched your search
@@ -17,6 +17,15 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Selected Discovery with Leader Skills — 30 October. Prototyped / Partial.**
|
||||
Basic and Advanced Discovery/Skills previews admit selected expansion decks,
|
||||
including E3 and unseated families, without Nexus or independent cards. Eight
|
||||
related files pass 48 cases. Public-start fixtures train leaders and exercise a
|
||||
real Discovery blow and shared Ambassador/Terror entry with controlled unopened
|
||||
Spice order and token placement. Advanced Harkonnen with actual E3 factions,
|
||||
Semuta, deeper interactions, complete games and deployed verification remain
|
||||
open. [Scope](DISCOVERY_PROTOTYPE.md#selected-decks-with-leader-skills--30-october-2026).
|
||||
|
||||
**Human Emperor before automated Extortion — 30 October. Prototyped /
|
||||
Partial.** In supported Basic and Advanced games, an automated Extortion
|
||||
payer now waits for its human Emperor ally's optional gift and Continue.
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# Discovery functional prototype
|
||||
|
||||
## Selected decks with Leader Skills — 30 October 2026
|
||||
|
||||
**Prototyped / Partial.** Fresh Basic and Advanced previews can combine the
|
||||
original Discovery tokens and Leader Skills with selected Ecaz/Moritani,
|
||||
Ixians/Tleilaxu and CHOAM/Richese decks without requiring Nexus or independent
|
||||
Ecaz cards. Selected families need not all be seated. Original Tech requires
|
||||
three seats; Strongholds require Advanced. The native Ecaz/Moritani shared
|
||||
arrival retains its provisional storm order. Advanced actual E3/Harkonnen and
|
||||
separate Semuta, nested reactions and other interactions remain guarded or
|
||||
unfinished. Started games are not converted.
|
||||
|
||||
Public-start fixtures train leaders, resolve an actual Discovery blow and the
|
||||
shared Ambassador/Terror entry, with controlled unopened Spice order and
|
||||
original token placement. These paths do not certify complete games, every
|
||||
module interaction or deployed behavior.
|
||||
|
||||
## Selected but unused E3 deck — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** A fresh Basic or Advanced preview can select only
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# Leader Skills first functional prototype
|
||||
|
||||
## Selected Discovery without unrelated cards — 30 October 2026
|
||||
|
||||
**Prototyped / Partial.** Mixed selected-deck Basic and Advanced tables can
|
||||
train leaders and select Discovery without Nexus or the independent Ecaz
|
||||
cards. Existing role, skill and token controls remain in use, with selected
|
||||
families allowed to be unseated. Native Ecaz/Moritani setup and shared entry
|
||||
use their existing handlers. Advanced actual E3/Harkonnen and other pending
|
||||
interactions remain separate. [Source and smoke scope](DISCOVERY_PROTOTYPE.md#selected-decks-with-leader-skills--30-october-2026).
|
||||
|
||||
## Selected Nexus with unused families — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** A fresh Basic or Advanced Nexus table can train
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Rule decision index
|
||||
|
||||
## Selected Discovery with Leader Skills — 30 October 2026
|
||||
|
||||
Discovery and Leader Skills can be selected with mixed expansion decks without
|
||||
requiring Nexus or independent Ecaz cards. Existing provisional shared-entry
|
||||
order and capture boundaries remain. [Scope](DISCOVERY_PROTOTYPE.md#selected-decks-with-leader-skills--30-october-2026).
|
||||
|
||||
## Human Emperor gift before automated Extortion — 30 October 2026
|
||||
|
||||
A human Emperor's automated allied payer waits for an event-bound Continue,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FACTIONS } from './catalog';
|
||||
import { independentDiscoveryProfile, mixedE3NexusRosterProfile, selectedNexusWithoutCardsProfile, selectedPhaseModuleRoster, type FactionModuleProfile } from './faction-module-profile';
|
||||
import { independentDiscoveryProfile, mixedE3NexusRosterProfile, selectedDiscoveryWithoutCardsProfile, 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,6 +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.leaderSkills && selectedDiscoveryLeaderSkillsProfile(game)) return true;
|
||||
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 ||
|
||||
@@ -59,6 +60,13 @@ export function discoveryModeSupported(game: FactionModuleProfile): boolean {
|
||||
return !(game.advanced && moritani && harkonnen);
|
||||
}
|
||||
|
||||
/** Original selected decks with Discovery and Skills are independent of Nexus
|
||||
* and the separate Ecaz cards. Setup owns physical inventory; this public
|
||||
* roster contract also remains valid during live Discovery frames. */
|
||||
export function selectedDiscoveryLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
|
||||
return selectedDiscoveryWithoutCardsProfile(game);
|
||||
}
|
||||
|
||||
/** The original E3 pair can use selected decks with Discovery without Nexus or cards. */
|
||||
export function e3DiscoveryOnlyOverlapProfile(game: FactionModuleProfile): boolean {
|
||||
return !game.nexusCards && !game.ecazTreachery && !game.leaderSkills &&
|
||||
|
||||
+3
-1
@@ -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, selectedNexusWithoutCardsProfile, selectedPhaseModuleRoster } from './faction-module-profile';
|
||||
import { independentDiscoveryProfile, independentNexusProfile, nexusCoreProfile, nativeFactionTechProfile, selectedDiscoveryWithoutCardsProfile, 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';
|
||||
@@ -25185,6 +25185,8 @@ function arrivalOverlapModeSupported(g: Game, revivalArrival?: RevivalEntryParen
|
||||
if (bareBasicNativeArrivalOverlap(g)) return true;
|
||||
if (bareAdvancedNativeArrivalOverlap(g)) return true;
|
||||
if (e3DiscoveryOnlyOverlapProfile(g)) return true;
|
||||
if (g.leaderSkills && selectedDiscoveryWithoutCardsProfile(g) &&
|
||||
byFaction(g, 'ecaz') && byFaction(g, 'moritani')) return true;
|
||||
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;
|
||||
|
||||
@@ -59,6 +59,17 @@ export function independentDiscoveryNexusProfile(game: NexusCoreConfiguration):
|
||||
return !!game.nexusCards && independentDiscoveryProfile(game);
|
||||
}
|
||||
|
||||
/** Selected Discovery decks and registered native seats remain independent of
|
||||
* Nexus and Ecaz's separate three cards, including before Skills are dealt. */
|
||||
export function selectedDiscoveryWithoutCardsProfile(game: NexusCoreConfiguration): boolean {
|
||||
const players = game.players;
|
||||
if (game.discoveryEnabled !== true || !game.expansions.length || !players ||
|
||||
game.nexusCards || game.ecazTreachery || game.semutaPreview ||
|
||||
!nexusCoreRosterProfile(game, true, true)) return false;
|
||||
return !(game.advanced && players.some(player => player.faction === 'harkonnen') &&
|
||||
players.some(player => player.faction === 'ecaz' || player.faction === 'moritani'));
|
||||
}
|
||||
|
||||
/** Original selected decks do not require their native factions or the
|
||||
* independent three-card variant. Hidden E3/Harkonnen compositions retain
|
||||
* their existing separately scoped paths. */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FACTIONS } from './catalog';
|
||||
import { independentDiscoveryProfile, independentNexusProfile, classicNexusCoreProfile, nexusCoreProfile, mixedE3NexusRosterProfile, selectedNexusWithoutCardsProfile } from './faction-module-profile';
|
||||
import { classicDiscoveryLeaderSkillsProfile } from './discovery-module-profile';
|
||||
import { classicDiscoveryLeaderSkillsProfile, selectedDiscoveryLeaderSkillsProfile } from './discovery-module-profile';
|
||||
|
||||
/** Public configuration only; never gate a skill on a hidden card or random deal. */
|
||||
export type LeaderSkillProfile = {
|
||||
@@ -305,7 +305,7 @@ export function classicTechLeaderSkillsProfile(game: LeaderSkillProfile): boolea
|
||||
export function strongholdLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
|
||||
if (!game.strongholdCards || game.advanced !== true) return false;
|
||||
if (independentTreacheryLeaderSkillsProfile(game)) return true;
|
||||
if (game.expansions.length) return selectedNexusLeaderSkillsProfile(game) || selectedDeckLeaderSkillsProfile(game) ||
|
||||
if (game.expansions.length) return selectedDiscoveryLeaderSkillsProfile(game) || selectedNexusLeaderSkillsProfile(game) || selectedDeckLeaderSkillsProfile(game) ||
|
||||
advancedMoritaniLeaderSkillsProfile(game) || nativeEcazLeaderSkillsProfile(game);
|
||||
const players = game.players;
|
||||
if (!players || players.length < (game.techTokens ? 3 : 2) || players.length > 6 ||
|
||||
@@ -328,7 +328,7 @@ export function strongholdLeaderSkillsProfile(game: LeaderSkillProfile): boolean
|
||||
/** Bounded integration of the ordinary Moritani roster, not Advanced assassination. */
|
||||
export function basicMoritaniLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
|
||||
if (game.advanced === false && game.players?.some(player => player.faction === 'moritani') &&
|
||||
(selectedNexusLeaderSkillsProfile(game) || selectedDeckLeaderSkillsProfile(game))) return true;
|
||||
(selectedDiscoveryLeaderSkillsProfile(game) || selectedNexusLeaderSkillsProfile(game) || selectedDeckLeaderSkillsProfile(game))) return true;
|
||||
return game.advanced === false && game.expansions.length === 1 &&
|
||||
game.expansions[0] === 'ecaz' && e3LeaderSkillModulesSupported(game) &&
|
||||
!!game.players?.some(player => player.faction === 'moritani') &&
|
||||
@@ -339,7 +339,7 @@ export function basicMoritaniLeaderSkillsProfile(game: LeaderSkillProfile): bool
|
||||
/** Existing bounded Advanced assassination roster, composed with native skills. */
|
||||
export function advancedMoritaniLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
|
||||
if (game.advanced === true && game.players?.some(player => player.faction === 'moritani') &&
|
||||
(selectedNexusLeaderSkillsProfile(game) || selectedDeckLeaderSkillsProfile(game))) return true;
|
||||
(selectedDiscoveryLeaderSkillsProfile(game) || selectedNexusLeaderSkillsProfile(game) || selectedDeckLeaderSkillsProfile(game))) return true;
|
||||
const players = game.players;
|
||||
if (game.advanced !== true || game.expansions.length !== 1 ||
|
||||
game.expansions[0] !== 'ecaz' || !players || players.length < 2 || players.length > 6 ||
|
||||
@@ -366,7 +366,7 @@ export function advancedMoritaniLeaderSkillsProfile(game: LeaderSkillProfile): b
|
||||
/** Ecaz's five native discs; the separate temporarily controlled Duke is not an assignment disc. */
|
||||
export function nativeEcazLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
|
||||
if (game.players?.some(player => player.faction === 'ecaz') &&
|
||||
(selectedNexusLeaderSkillsProfile(game) || selectedDeckLeaderSkillsProfile(game))) return true;
|
||||
(selectedDiscoveryLeaderSkillsProfile(game) || selectedNexusLeaderSkillsProfile(game) || selectedDeckLeaderSkillsProfile(game))) return true;
|
||||
const players = game.players;
|
||||
if (typeof game.advanced !== 'boolean' || game.expansions.length !== 1 ||
|
||||
game.expansions[0] !== 'ecaz' || !players || players.length < 2 || players.length > 6 ||
|
||||
@@ -442,7 +442,7 @@ export function selectedDeckLeaderSkillsProfile(game: LeaderSkillProfile): boole
|
||||
|
||||
export function nativeExpansionLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
|
||||
return (game.expansions.length > 0 && independentTreacheryLeaderSkillsProfile(game)) ||
|
||||
selectedNexusLeaderSkillsProfile(game) || mixedE3NexusLeaderSkillsProfile(game) || mixedE1E2NexusLeaderSkillsProfile(game) || pairedE3NexusLeaderSkillsProfile(game) ||
|
||||
selectedDiscoveryLeaderSkillsProfile(game) || selectedNexusLeaderSkillsProfile(game) || mixedE3NexusLeaderSkillsProfile(game) || mixedE1E2NexusLeaderSkillsProfile(game) || pairedE3NexusLeaderSkillsProfile(game) ||
|
||||
basicMoritaniLeaderSkillsProfile(game) || selectedDeckLeaderSkillsProfile(game) ||
|
||||
advancedMoritaniLeaderSkillsProfile(game) || nativeEcazLeaderSkillsProfile(game);
|
||||
}
|
||||
|
||||
@@ -5927,6 +5927,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
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 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 selected-deck Discovery previews can also train Leader Skills without Nexus or independent Ecaz cards, including mixed and unseated families. Advanced Harkonnen with an actual Ecaz or Moritani seat, Semuta and deeper interactions keep their existing limitations.',
|
||||
'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.',
|
||||
|
||||
@@ -94,9 +94,11 @@ void test('Discovery cannot redeal started components or silently remove another
|
||||
for (const game of overlays) assert.throws(() => initializeDiscoveryGameForAudit(game), RuleError);
|
||||
});
|
||||
|
||||
void test('Discovery Skills cannot silently drop unsupported native families or unrelated modules and previews', () => {
|
||||
const unsupported = lobby(['ixians', 'ecaz', 'guild'], ['ix', 'ecaz']);
|
||||
assert.throws(() => initializeLeaderSkillsGameForAudit(unsupported), RuleError);
|
||||
void test('Discovery Skills retain selected native families and reject invalid phase modules', () => {
|
||||
const selected = lobby(['ixians', 'ecaz', 'guild'], ['ix', 'ecaz']);
|
||||
const started = initializeLeaderSkillsGameForAudit(selected);
|
||||
assert.ok(started.leaderSkills && started.discoveries);
|
||||
assert.deepEqual(started.players.map(player => player.faction), ['ixians', 'ecaz', 'guild']);
|
||||
const basicStrongholds = lobby(['guild', 'emperor', 'harkonnen'], []);
|
||||
basicStrongholds.advanced = false; basicStrongholds.strongholdCards = createStrongholdCards();
|
||||
assert.throws(() => initializeLeaderSkillsGameForAudit(basicStrongholds), RuleError);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
import { nextPairedChoamSkillsStep } from './fixture-paired-choam-nexus-skills';
|
||||
import { actSelectedIndependentNexus, advanceSelectedIndependentNexus,
|
||||
type SelectedIndependentNexusStep } from './fixture-selected-independent-nexus-cards';
|
||||
|
||||
export type DiscoverySkillsOptions = {
|
||||
advanced: boolean;
|
||||
factions: readonly FactionId[];
|
||||
expansions?: Game['expansions'];
|
||||
};
|
||||
|
||||
/** Fresh public lobby, real original private training and native setup. Only
|
||||
* the original random lottery is fixed; no cards, leaders or forces are added. */
|
||||
export function selectedDiscoverySkillsLobby(options: DiscoverySkillsOptions) {
|
||||
const [host, ...others] = options.factions;
|
||||
const expansions = options.expansions ?? ['ecaz', 'ix', 'choam'];
|
||||
let game = withClassicDiscoveryNexusLottery(() => createGame('SELECTEDDISCOVERYSKILLS',
|
||||
newPlayer(host, host, host), options.advanced, expansions));
|
||||
for (const faction of others) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const actions: SelectedIndependentNexusStep[] = [];
|
||||
const step = (actor: string, action: Action) => {
|
||||
game = actSelectedIndependentNexus(game, actor, action, actions);
|
||||
};
|
||||
if (expansions.includes('ix') && expansions.includes('choam'))
|
||||
step(host, {type:'moduleOption', module:'kullPreview', enabled:false});
|
||||
step(host, {type:'moduleOption', module:'leaderSkills', enabled:true});
|
||||
step(host, {type:'moduleOption', module:'discoveries', enabled:true});
|
||||
for (const player of game.players) step(player.id, {type:'ready'});
|
||||
return {game, host, step, actions};
|
||||
}
|
||||
|
||||
export function selectedDiscoverySkillsStart(options: DiscoverySkillsOptions) {
|
||||
const fixture = selectedDiscoverySkillsLobby(options);
|
||||
let game = fixture.game;
|
||||
const lobby = structuredClone(game);
|
||||
const start: Action = options.advanced ? {type:'start', advancedPreview:true} : {type:'start'};
|
||||
game = actSelectedIndependentNexus(game, fixture.host, start, fixture.actions);
|
||||
const setup = structuredClone(game);
|
||||
for (let count = 0; game.status === 'setup' && count < 300; count++) {
|
||||
const next = nextPairedChoamSkillsStep(game);
|
||||
assert.ok(next, `Original setup must expose a choice at ${game.setupStage ?? game.decision?.kind}`);
|
||||
game = actSelectedIndependentNexus(game, next.actor, next.action, fixture.actions);
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
return {...fixture, game, lobby, setup};
|
||||
}
|
||||
|
||||
/** Reorder only the unopened original Spice Cards. The engine resolves the
|
||||
* Discovery and the following bidding and movement through ordinary actions. */
|
||||
export function advanceDiscoverySkillsBlow(game: Game, actions: SelectedIndependentNexusStep[]) {
|
||||
for (const position of game.advanced ? [0, 1] : [0]) {
|
||||
const index = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card &&
|
||||
(position === 0 ? card.discovery === 'discovery-hagga-basin' : !card.discovery));
|
||||
assert.ok(index >= position);
|
||||
[game.spiceDeck[position], game.spiceDeck[index]] = [game.spiceDeck[index], game.spiceDeck[position]];
|
||||
}
|
||||
return advanceSelectedIndependentNexus(game, state => state.turn === 1 && state.phase === 5 &&
|
||||
!state.phaseOpening && !state.response && !state.decision && !state.truthtrance &&
|
||||
!state.pendingTreacheryDiscard, actions);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import { placeAmbassador } from '../game/ecaz-ambassadors';
|
||||
import { placeTerror } from '../game/moritani-terror';
|
||||
import { actBasicSemutaAmbassador as act, basicSemutaAmbassadorClean as clean,
|
||||
basicSemutaAmbassadorPlayer as player, nextBasicSemutaAmbassadorStep } from './fixture-basic-semuta-ambassador';
|
||||
import { nextPairedChoamSkillsStep } from './fixture-paired-choam-nexus-skills';
|
||||
|
||||
export { act, clean, player };
|
||||
|
||||
/** Real public mixed-family setup and skills training. Only unopened Spice
|
||||
* order and original Ambassador/Terror placements are controlled. */
|
||||
export function selectedDiscoverySkillsOverlapFixture(advanced: boolean) {
|
||||
let game = createGame('DISCOVERYSKILLSOVERLAP', newPlayer('ecaz', 'Ecaz', 'ecaz'), advanced,
|
||||
['ecaz', 'ix', 'choam']);
|
||||
for (const faction of ['moritani', 'ixians', 'choam', 'guild'] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
game = act(game, 'ecaz', {type:'moduleOption', module:'kullPreview', enabled:false});
|
||||
game = act(game, 'ecaz', {type:'moduleOption', module:'discoveries', enabled:true});
|
||||
game = act(game, 'ecaz', {type:'moduleOption', module:'leaderSkills', enabled:true});
|
||||
for (const seat of game.players) game = act(game, seat.id, {type:'ready'});
|
||||
const lobby = structuredClone(game);
|
||||
game = act(game, 'ecaz', {type:'start', ...(advanced ? {advancedPreview:true} : {})});
|
||||
const setup = structuredClone(game);
|
||||
const advance = (initial: Game, until: (state: Game) => boolean) => {
|
||||
let state = initial;
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(state)) return state;
|
||||
assert.notEqual(state.status, 'finished');
|
||||
const step = state.status === 'setup' && state.setupStage === 'leaderSkills'
|
||||
? nextPairedChoamSkillsStep(state) : nextBasicSemutaAmbassadorStep(state);
|
||||
assert.ok(step, `No original continuation at ${state.status}/${state.phase}/${state.decision?.kind}`);
|
||||
state = act(state, step.actor, step.action);
|
||||
}
|
||||
throw Error('The mixed Discovery/Skills game did not reach its requested boundary');
|
||||
};
|
||||
game = advance(game, state => state.status === 'playing');
|
||||
assert.equal(game.leaderSkills?.assignments.length, game.players.length);
|
||||
assert.ok(game.discoveries && game.ecazAmbassadors && game.moritaniTerror);
|
||||
assert.ok(!game.nexusCards && !game.ecazTreachery);
|
||||
for (const position of advanced ? [0, 1] : [0]) {
|
||||
const index = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card &&
|
||||
(position === 0 ? card.discovery === 'discovery-hagga-basin' : !card.discovery));
|
||||
assert.ok(index >= position);
|
||||
[game.spiceDeck[position], game.spiceDeck[index]] = [game.spiceDeck[index], game.spiceDeck[position]];
|
||||
}
|
||||
game = advance(game, state => state.turn === 1 && state.phase === 5 && state.active === 'guild' && clean(state));
|
||||
const beforeMarkers = structuredClone(game);
|
||||
const token = game.ecazAmbassadors!.tokens.find(row => row.effect === 'ecaz' && row.zone === 'supply');
|
||||
const terror = game.moritaniTerror!.tokens.find(row => row.kind === 'robbery' && row.status === 'available');
|
||||
assert.ok(token && terror);
|
||||
const destination = 'habbanya_ridge_sietch', sector = 17;
|
||||
game.ecazAmbassadors = placeAmbassador(game.ecazAmbassadors!, token.id, {
|
||||
turn:game.turn, availableSpice:player(game,'ecaz').spice,
|
||||
destination:{id:destination,stronghold:true,inStorm:false,allowed:true},
|
||||
}).state;
|
||||
game.moritaniTerror = placeTerror(game.moritaniTerror!, terror.id, destination, game.turn);
|
||||
const shipment: Action = {type:'ship', territory:destination, sector, amount:1, elite:0, allyPayment:0};
|
||||
return {game, lobby, setup, beforeMarkers, destination, sector, token:token.id,
|
||||
terror:terror.id, shipment, advance};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { act, player, selectedDiscoverySkillsOverlapFixture } from './selected-discovery-skills-overlap-fixture';
|
||||
|
||||
for (const advanced of [false, true]) void test(
|
||||
`${advanced ? 'Advanced' : 'Basic'} mixed Discovery and Skills retain the original Ecaz/Moritani arrival`, () => {
|
||||
const f = selectedDiscoverySkillsOverlapFixture(advanced);
|
||||
assert.equal(f.lobby.discoveryEnabled, true);
|
||||
assert.ok(f.setup.discoveries && f.setup.leaderSkills);
|
||||
assert.equal(f.game.leaderSkills!.assignments.length, f.game.players.length);
|
||||
assert.ok(f.beforeMarkers.spiceDiscard.flat().some(card =>
|
||||
'territory' in card && card.discovery === 'discovery-hagga-basin'));
|
||||
assert.equal(f.beforeMarkers.discoveries!.tokens.filter(token => token.status === 'placed').length, 1);
|
||||
assert.ok(!f.game.nexusCards && !f.game.ecazTreachery);
|
||||
const moritaniSpice = player(f.game, 'moritani').spice;
|
||||
const duke = structuredClone(f.game.dukeVidal!.leader);
|
||||
let game = act(f.game, 'guild', f.shipment);
|
||||
game = f.advance(game, state => !!state.pendingArrivalOverlap);
|
||||
const afterShipment = player(game, 'guild').spice;
|
||||
const stolen = Math.ceil(afterShipment / 2);
|
||||
assert.equal(player(game, 'guild').forces[`${f.destination}:${f.sector}`], 1);
|
||||
const first = game.pendingArrivalOverlap!.first;
|
||||
const responder = first === 'ambassador' ? 'ecaz' : 'moritani';
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, responder);
|
||||
view.players.find(row => row.id === responder)!.bot = difficulty;
|
||||
const choice = botActions(view)[0]; assert.ok(choice, difficulty);
|
||||
assert.doesNotThrow(() => act(game, responder, choice), difficulty);
|
||||
}
|
||||
const ambassador = () => {
|
||||
const entry = game.pendingAmbassador; assert.ok(entry);
|
||||
assert.equal(entry.token, f.token);
|
||||
game = act(game, 'ecaz', {type:'decision', event:entry.event, trigger:true, beneficiary:'ecaz', choice:'duke'});
|
||||
};
|
||||
const robbery = () => {
|
||||
assert.equal(game.pendingTerrorEntry?.token, f.terror);
|
||||
game = act(game, 'moritani', {type:'decision', reveal:true});
|
||||
game = act(game, 'moritani', {type:'decision', choice:'spice'});
|
||||
};
|
||||
if (first === 'ambassador') { ambassador(); robbery(); }
|
||||
else { robbery(); ambassador(); }
|
||||
assert.equal(game.pendingArrivalOverlap ?? null, null);
|
||||
assert.equal(game.pendingAmbassador ?? null, null);
|
||||
assert.equal(game.pendingTerrorEntry ?? null, null);
|
||||
assert.equal(player(game, 'guild').spice, afterShipment - stolen);
|
||||
assert.equal(player(game, 'moritani').spice, moritaniSpice + stolen);
|
||||
assert.equal(player(game, 'guild').forces[`${f.destination}:${f.sector}`], 1);
|
||||
assert.equal(game.moritaniTerror!.tokens.find(row => row.id === f.terror)?.status, 'removed');
|
||||
assert.equal(game.ecazAmbassadors!.tokens.find(row => row.id === f.token)?.zone, 'supply');
|
||||
assert.deepEqual([game.dukeVidal?.controller, game.dukeVidal?.source], ['ecaz', 'ecaz']);
|
||||
assert.deepEqual(game.dukeVidal?.leader, duke);
|
||||
assert.equal(game.leaderSkills!.assignments.length, game.players.length);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,92 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { bureaucratPaymentModeSupported } from '../game/bureaucrat-payment';
|
||||
import { discoveryModeSupported, selectedDiscoveryLeaderSkillsProfile } from '../game/discovery-module-profile';
|
||||
import { selectedDiscoveryWithoutCardsProfile, type FactionModuleProfile } from '../game/faction-module-profile';
|
||||
import { advancedMoritaniLeaderSkillsProfile, basicMoritaniLeaderSkillsProfile,
|
||||
nativeDiscoveryLeaderSkillsProfile, nativeEcazLeaderSkillsProfile,
|
||||
ordinaryLeaderSkillModeSupported, strongholdLeaderSkillsProfile } from '../game/leader-skill-profile';
|
||||
|
||||
const base: FactionModuleProfile = {
|
||||
advanced: false,
|
||||
expansions: ['ix', 'choam', 'ecaz'],
|
||||
players: [{faction:'atreides'}, {faction:'guild'}, {faction:'emperor'}],
|
||||
discoveryEnabled: true,
|
||||
leaderSkills: {},
|
||||
};
|
||||
|
||||
void test('selected Discovery and Skills use the shared registered roster without requiring native seats', () => {
|
||||
const rows: FactionModuleProfile[] = [
|
||||
base,
|
||||
{...base, advanced:true},
|
||||
{...base, expansions:['ecaz'], players:[{faction:'atreides'}, {faction:'guild'}]},
|
||||
{...base, expansions:['ix', 'ecaz'], players:[{faction:'ixians'}, {faction:'guild'}]},
|
||||
{...base, players:[{faction:'ixians'}, {faction:'ecaz'}, {faction:'moritani'}]},
|
||||
{...base, players:[{faction:'ixians'}, {faction:'ecaz'}, {faction:'guild'}],
|
||||
discoveries:{}, discoveryStash:{}, greatMaker:{}, ecazOccupyPreview:true},
|
||||
{...base, advanced:true, players:[{faction:'moritani'}, {faction:'atreides'}, {faction:'guild'}],
|
||||
moritaniAssassinatePreview:true},
|
||||
{...base, advanced:true, homeworlds:{}, techTokens:{}, strongholdCards:{}},
|
||||
{...base, advanced:true, players:[{faction:'harkonnen'}, {faction:'ixians'}]},
|
||||
];
|
||||
for (const row of rows) {
|
||||
const label = JSON.stringify(row);
|
||||
assert.equal(selectedDiscoveryWithoutCardsProfile(row), true, label);
|
||||
assert.equal(selectedDiscoveryLeaderSkillsProfile(row), true, label);
|
||||
assert.equal(discoveryModeSupported(row), true, label);
|
||||
assert.equal(nativeDiscoveryLeaderSkillsProfile(row), true, label);
|
||||
assert.equal(ordinaryLeaderSkillModeSupported(row), true, label);
|
||||
assert.equal(bureaucratPaymentModeSupported(row), true, label);
|
||||
}
|
||||
// The pre-deal lobby has not allocated the physical Skills deck yet.
|
||||
const lobby: FactionModuleProfile = {...base, leaderSkills:undefined};
|
||||
assert.equal(selectedDiscoveryLeaderSkillsProfile(lobby), true);
|
||||
assert.equal(strongholdLeaderSkillsProfile({...base, advanced:true, strongholdCards:{}}), true);
|
||||
});
|
||||
|
||||
void test('selected Discovery Skills preserve native Ecaz and Moritani participation', () => {
|
||||
const pair = {...base, players:[{faction:'ecaz'}, {faction:'moritani'}, {faction:'guild'}]};
|
||||
assert.equal(nativeEcazLeaderSkillsProfile(pair), true);
|
||||
assert.equal(basicMoritaniLeaderSkillsProfile(pair), true);
|
||||
assert.equal(advancedMoritaniLeaderSkillsProfile(pair), false);
|
||||
const advanced = {...pair, advanced:true, moritaniAssassinatePreview:true};
|
||||
assert.equal(nativeEcazLeaderSkillsProfile(advanced), true);
|
||||
assert.equal(advancedMoritaniLeaderSkillsProfile(advanced), true);
|
||||
});
|
||||
|
||||
void test('selected Discovery Skills reject illegal seats, decks, overlays and phase-module prerequisites', () => {
|
||||
const rejected: FactionModuleProfile[] = [
|
||||
{...base, discoveryEnabled:false},
|
||||
{...base, expansions:[]},
|
||||
{...base, expansions:['ix', 'ix']},
|
||||
{...base, expansions:['ix', 'unknown']},
|
||||
{...base, players:[{faction:'atreides'}]},
|
||||
{...base, players:[{faction:'atreides'}, {faction:'atreides'}]},
|
||||
{...base, players:[{faction:'atreides'}, {faction:'unknown'}]},
|
||||
{...base, expansions:['ix'], players:[{faction:'choam'}, {faction:'atreides'}]},
|
||||
{...base, players:[{faction:'atreides'}, {faction:'guild'}], techTokens:{}},
|
||||
{...base, strongholdCards:{}},
|
||||
{...base, nexusCards:{}},
|
||||
{...base, ecazTreachery:true},
|
||||
{...base, semutaPreview:true},
|
||||
{...base, kullPreview:true},
|
||||
{...base, nexusKullPreview:true},
|
||||
{...base, guildBetrayalPreview:true},
|
||||
{...base, nexusIxianBetrayalPreview:true},
|
||||
{...base, moritaniAssassinatePreview:true},
|
||||
];
|
||||
for (const row of rejected) {
|
||||
const label = JSON.stringify(row);
|
||||
assert.equal(selectedDiscoveryWithoutCardsProfile(row), false, label);
|
||||
assert.equal(selectedDiscoveryLeaderSkillsProfile(row), false, label);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Advanced trained Harkonnen with actual E3 native remains excluded', () => {
|
||||
for (const faction of ['ecaz', 'moritani']) {
|
||||
const players = [{faction:'harkonnen'}, {faction}];
|
||||
assert.equal(selectedDiscoveryLeaderSkillsProfile({...base, players}), true, faction);
|
||||
assert.equal(selectedDiscoveryLeaderSkillsProfile({...base, players, advanced:true}), false, faction);
|
||||
assert.equal(discoveryModeSupported({...base, players, advanced:true}), false, faction);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { DISCOVERY_CARD_PLACEMENTS, validateDiscoveryState } from '../game/discoveries';
|
||||
import { validateLeaderSkills } from '../game/leader-skills';
|
||||
import { selectedDiscoverySkillsLobby, selectedDiscoverySkillsStart,
|
||||
advanceDiscoverySkillsBlow } from './selected-discovery-skills-fixture';
|
||||
|
||||
for (const advanced of [false, true]) for (const factions of [
|
||||
['atreides', 'guild', 'emperor'],
|
||||
['ixians', 'ecaz', 'guild'],
|
||||
['moritani', 'choam', 'guild'],
|
||||
['ecaz', 'moritani', 'guild'],
|
||||
] as const) void test(
|
||||
`public ${advanced ? 'Advanced' : 'Basic'} mixed Discovery/Skills: ${factions.join('/')}`, () => {
|
||||
const fixture = selectedDiscoverySkillsStart({advanced, factions});
|
||||
const game = fixture.game;
|
||||
assert.deepEqual(game.expansions, ['ecaz', 'ix', 'choam']);
|
||||
assert.equal(game.discoveryEnabled, true);
|
||||
assert.ok(game.discoveries);
|
||||
assert.equal(game.discoveries.tokens.length, 8);
|
||||
assert.ok(!game.nexusCards && !game.ecazTreachery);
|
||||
assert.equal(game.leaderSkills!.assignments.length, factions.length);
|
||||
assert.equal(fixture.actions.filter(step => step.action.type === 'leaderSkill').length, factions.length);
|
||||
assert.ok(fixture.actions.some(step => step.action.type === 'moduleOption' && step.action.module === 'discoveries'));
|
||||
validateLeaderSkills(game.leaderSkills!, game.players);
|
||||
validateDiscoveryState(game.discoveries!);
|
||||
assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)]
|
||||
.map(card => card.id).sort(), treacheryDeck(game.expansions).map(card => card.id).sort());
|
||||
if (game.players.some(player => player.faction === 'ecaz')) {
|
||||
assert.equal(game.ecazOccupyPreview, true);
|
||||
assert.ok(game.ecazAmbassadors);
|
||||
}
|
||||
if (game.players.some(player => player.faction === 'moritani')) {
|
||||
assert.ok(game.moritaniTerror);
|
||||
if (advanced) assert.ok(game.moritaniAssassinate);
|
||||
}
|
||||
if (game.players.some(player => player.faction === 'ixians')) assert.ok(game.mobileStronghold);
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) void test(
|
||||
`${advanced ? 'Advanced' : 'Basic'} mixed Discovery/Skills reaches a real Discovery Spice Blow`, () => {
|
||||
const fixture = selectedDiscoverySkillsStart({advanced, factions:['ixians', 'ecaz', 'guild']});
|
||||
const game = advanceDiscoverySkillsBlow(structuredClone(fixture.game), fixture.actions);
|
||||
assert.ok(game.spiceDiscard.flat().some(card => 'territory' in card && card.discovery === 'discovery-hagga-basin'));
|
||||
const placed = game.discoveries!.tokens.filter(token => token.status === 'placed');
|
||||
assert.equal(placed.length, 1);
|
||||
const source = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
|
||||
assert.deepEqual([placed[0].type, placed[0].territory, placed[0].sector],
|
||||
[source.type, source.territory, source.sector]);
|
||||
validateDiscoveryState(game.discoveries!);
|
||||
validateLeaderSkills(game.leaderSkills!, game.players);
|
||||
assert.ok(!game.nexusCards && !game.ecazTreachery);
|
||||
assert.ok(game.active);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, game.active);
|
||||
view.players.find(player => player.id === game.active)!.bot = difficulty;
|
||||
const action = botActions(view)[0]; assert.ok(action, difficulty);
|
||||
assert.doesNotThrow(() => applyAction(game, game.active!, action), difficulty);
|
||||
}
|
||||
});
|
||||
|
||||
for (const faction of ['ecaz', 'moritani'] as const) void test(
|
||||
`Advanced mixed Discovery/Skills with Harkonnen and ${faction} stays guarded`, () => {
|
||||
const fixture = selectedDiscoverySkillsLobby({advanced:true, factions:[faction, 'harkonnen', 'ixians']});
|
||||
const before = structuredClone(fixture.game);
|
||||
assert.throws(() => applyAction(fixture.game, fixture.host, {type:'start', advancedPreview:true}));
|
||||
assert.deepEqual(fixture.game, before);
|
||||
});
|
||||
Reference in new issue
Block a user