Enable Basic Nexus Kull and native reactions
Verify and publish checkpoint container / container (push) Successful in 11m12s
Verify and publish checkpoint container / container (push) Successful in 11m12s
This commit is contained in:
1 parent
5b66628399
commit
95ebd7f976
15 files changed
+388
-21
No files matched your search
@@ -248,13 +248,14 @@ the specific Karama effect; Basic retains phase-wide prevention. Advanced phase
|
||||
expiry is provisional. Unpayable overbid recovery remains guarded. See the [selected policy and boundaries](docs/CHOAM_KULL_SOURCE_UPDATE.md).
|
||||
This does not enable public expansion starts or certify full CHOAM.
|
||||
|
||||
For local **CHOAM Nexus Cunning Kull**, use `node --import tsx tools/start-prototype.ts --profile nexus-kull`
|
||||
on a fresh ready Basic/Advanced CHOAM-plus-classic lobby with `choam,ix` Treachery
|
||||
and Nexus alone. It omits physical Sandtrout. The same exact-version/private-backup
|
||||
safeguards apply. Choose printed Kull or a canonical own Treachery fuel:
|
||||
Fresh Basic CHOAM-plus-classic games with `choam,ix` decks and Nexus alone now
|
||||
enable the **CHOAM Nexus Cunning Kull** preview by default; the host can turn
|
||||
it off. The original Ix Sandtrout remains in the Spice deck. The separate
|
||||
local Basic/Advanced audit uses `node --import tsx tools/start-prototype.ts --profile nexus-kull`
|
||||
with the same exact-version/private-backup safeguards. Choose printed Kull or a canonical own Treachery fuel:
|
||||
Nexus spends on accepted declaration, fuel only on success; distinct prevention
|
||||
retains fuel while leaving Nexus spent. See [verified bounded runtime and limits](docs/NEXUS_CHOAM_RUNTIME.md#bounded-nexus-kull-profile).
|
||||
Older printed-only games do not gain Nexus choices, and public starts stay gated.
|
||||
Older printed-only games do not gain Nexus choices; other combinations remain incomplete.
|
||||
|
||||
For local **Richese Nexus Betrayal** testing, use `node --import tsx tools/start-prototype.ts --profile richese-betrayal`
|
||||
on a fresh ready Basic/Advanced CHOAM/Richese-plus-classic lobby with the `choam`
|
||||
|
||||
@@ -17,6 +17,17 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Basic native Nexus reactions and Kull — 30 October. Prototyped / Partial.**
|
||||
Fresh Basic E1/E2 Nexus starts now initialize native reactions when all
|
||||
selected families are seated. Harkonnen Use/Pass reaches the original Mentat
|
||||
replacement, including the supported Richese/Semuta start. Basic CHOAM/classic
|
||||
games with Ix and CHOAM decks and Nexus alone enable printed and Nexus-fueled
|
||||
Kull by default, with host opt-out and the original Sandtrout. Ten related
|
||||
files pass 63 cases; public journeys use real actions with staged conserved
|
||||
cards, positions and earned Nexus identities. Other module combinations,
|
||||
nested sources, complete games and deployed acceptance remain open.
|
||||
[Scope](NEXUS_CARD_RULES.md#basic-native-nexus-reactions-and-kull--30-october-2026).
|
||||
|
||||
**Basic public Nexus with Semuta — 30 October. Prototyped / Partial.**
|
||||
Fresh Basic CHOAM-deck Richese/Nexus starts use supported Semuta reactions,
|
||||
including CHOAM trade and Betrayal and simultaneous Sapho question ordering.
|
||||
|
||||
@@ -877,6 +877,24 @@ the Cheap Hero identity. Other rosters, nested effects, complete games and
|
||||
deployed acceptance remain open. The later public Semuta entry below removes
|
||||
the stated Emperor and Sapho blockers for its narrower CHOAM-only Basic scope.
|
||||
|
||||
## Basic native Nexus reactions and Kull — 30 October 2026
|
||||
|
||||
**Prototyped / Partial.** Fresh Basic Nexus games with selected Ix and/or
|
||||
CHOAM decks and no other modules initialize their native reactions even when
|
||||
every selected family is seated. Harkonnen's existing Betrayal can follow an
|
||||
actual matching call in those games, including the supported CHOAM/Richese
|
||||
Semuta start; Use or Pass retains the original battle and Mentat continuation.
|
||||
Ixian and Guild reactions retain their native setup and controls.
|
||||
|
||||
Fresh Basic CHOAM/classic Nexus games with the Ix and CHOAM decks now enable
|
||||
the existing source-aware Kull preview by default, with the normal host opt-out.
|
||||
The physical Sandtrout remains in the Ix Spice deck. A public journey exercises
|
||||
printed and Nexus-fueled Kull after drawing Sandtrout, including decline and a
|
||||
distinct counter. Card order, custody, force positions and an earned Nexus
|
||||
identity are staged; setup and actions are real. The existing provisional
|
||||
counter and response ordering remains. Richese/Kull, other modules, broader
|
||||
nested reactions, full games and deployed acceptance remain open.
|
||||
|
||||
## Basic public Semuta and Nexus — 30 October 2026
|
||||
|
||||
**Prototyped / Partial.** A fresh Basic Richese game with the CHOAM deck,
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# Rule decision index
|
||||
|
||||
## Basic native Nexus reactions and Kull — 30 October 2026
|
||||
|
||||
Fresh Basic E1/E2 Nexus starts retain native Harkonnen, Ixian and Guild
|
||||
reaction initialization even with every selected family seated. The bounded
|
||||
CHOAM/classic Nexus Kull preview is now available by default; the existing
|
||||
counter and response ordering stays provisional.
|
||||
[Scope](NEXUS_CARD_RULES.md#basic-native-nexus-reactions-and-kull--30-october-2026).
|
||||
|
||||
## Basic public Semuta and Nexus — 30 October 2026
|
||||
|
||||
Fresh CHOAM-deck Richese Nexus starts compose existing Semuta, CHOAM and
|
||||
|
||||
+4
-5
@@ -8067,10 +8067,10 @@ function suspendedControlsIntegrity(
|
||||
}
|
||||
/** Original native Nexus roster/modules with no extra overlay-specific setup. */
|
||||
function semutaNexusProfile(g: Game): boolean {
|
||||
return !!g.nexusCards && (g.leaderSkills
|
||||
return !!g.nexusCards && (g.semutaPreview === true && nexusCoreProfile(g) || (g.leaderSkills
|
||||
? pairedNexusLeaderSkillsProfile(g) || singleE1E2NexusLeaderSkillsProfile(g) ||
|
||||
mixedE1E2NexusLeaderSkillsProfile(g)
|
||||
: pairedNexusModulesProfile(g) || singleE1E2NexusProfile(g) || mixedE1E2NexusProfile(g));
|
||||
: pairedNexusModulesProfile(g) || singleE1E2NexusProfile(g) || mixedE1E2NexusProfile(g)));
|
||||
}
|
||||
/** Original classic occupation or a focused Advanced Tleilax SEND profile.
|
||||
* Other optional envelopes remain separate; no recorded game is redealt. */
|
||||
@@ -11212,6 +11212,7 @@ function startModules(state: Game): Game {
|
||||
const g = structuredClone(state);
|
||||
delete g.lobbyOptions;
|
||||
if (lobbyKullEnabled(state)) {
|
||||
if (g.nexusCards && g.advanced === false) return initializeNexusKullGameForAudit(g);
|
||||
const supportedBasic = g.advanced === false && (g.expansions.length === 2 ||
|
||||
g.expansions.length === 3 && g.expansions.includes('ecaz')) && g.expansions.includes('choam') &&
|
||||
g.expansions.includes('ix') && !!byFaction(g,'choam') &&
|
||||
@@ -13729,8 +13730,6 @@ export function initializeNexusKullGameForAudit(state: Game): Game {
|
||||
const g = initializeSetupGameForAudit(state, false, true, false, false, false, false, true);
|
||||
g.kullPreview = true;
|
||||
g.nexusKullPreview = true;
|
||||
// Ix supplies Treachery cards here, not the optional Sandtrout Spice card.
|
||||
g.spiceDeck = shuffle(spiceDeck());
|
||||
return g;
|
||||
}
|
||||
/** Prototype-only Discovery setup. Public starts stay gated while remaining effects are connected. */
|
||||
@@ -20273,7 +20272,7 @@ function kullIntegrity(g: Game): void {
|
||||
g.players.some(p => p.faction === 'choam') &&
|
||||
g.players.every(p => p.faction === 'choam' || faction(p.faction).expansion === 'base') &&
|
||||
!g.homeworlds && !g.leaderSkills && !g.discoveryEnabled && !g.discoveries &&
|
||||
!g.discoveryStash && !g.greatMaker && !g.sandtrout &&
|
||||
!g.discoveryStash && !g.greatMaker &&
|
||||
!g.techTokens && !g.strongholdCards && !g.ecazTreachery && !g.semutaPreview &&
|
||||
!g.richeseBetrayalPreview && !g.richeseBetrayal && !g.pendingRicheseBetrayal,
|
||||
'The Nexus Kull profile has lost its supported native inventory and modules.');
|
||||
|
||||
@@ -36,6 +36,20 @@ type NexusCoreConfiguration = Partial<FactionModuleProfile> & Pick<FactionModule
|
||||
sandtrout?: unknown; moritaniAssassinatePreview?: unknown;
|
||||
};
|
||||
|
||||
/** The pre-existing Nexus Kull audit also accepts Advanced CHOAM/classic
|
||||
* seats; retain its native reaction stamps without opening a public mode. */
|
||||
function nexusKullCore(game: NexusCoreConfiguration): boolean {
|
||||
return typeof game.advanced === 'boolean' && !!game.nexusCards &&
|
||||
game.kullPreview === true && game.nexusKullPreview === true && !game.semutaPreview &&
|
||||
game.expansions.length === 2 && game.expansions.includes('ix') && game.expansions.includes('choam') &&
|
||||
!!game.players?.some(player => player.faction === 'choam') &&
|
||||
game.players.every(player => player.faction === 'choam' ||
|
||||
FACTIONS.some(faction => faction.id === player.faction && faction.expansion === 'base')) &&
|
||||
!game.homeworlds && !game.leaderSkills && !game.techTokens && !game.strongholdCards &&
|
||||
!game.ecazTreachery && !game.discoveryEnabled && !game.discoveries && !game.discoveryStash &&
|
||||
!game.greatMaker && !game.advancedPreview && !game.richeseBetrayalPreview;
|
||||
}
|
||||
|
||||
/** Classic Nexus12 with selected base33/independent36, optional all14, original
|
||||
* Homeworlds and phase modules. Native effect stamps are not selected overlays. */
|
||||
export function classicNexusCoreProfile(game: NexusCoreConfiguration): boolean {
|
||||
@@ -79,6 +93,27 @@ export function selectedDiscoverySkillsSemutaProfile(game: NexusCoreConfiguratio
|
||||
selectedDiscoveryWithoutCardsProfile({...game, semutaPreview: undefined});
|
||||
}
|
||||
|
||||
/** Fresh Basic E1/E2 Nexus without additional modules. Native reaction stamps
|
||||
* are part of this core; Semuta and Nexus Kull keep their physical prerequisites. */
|
||||
function basicNativeNexusCore(game: NexusCoreConfiguration): boolean {
|
||||
const players = game.players;
|
||||
if (game.advanced !== false || !game.nexusCards || !players || !game.expansions.length ||
|
||||
!game.expansions.every(id => id === 'ix' || id === 'choam') ||
|
||||
game.homeworlds || game.leaderSkills || game.techTokens || game.strongholdCards ||
|
||||
game.ecazTreachery || game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker ||
|
||||
game.advancedPreview || game.richeseBetrayalPreview) return false;
|
||||
if (game.semutaPreview && (game.semutaPreview !== true || game.kullPreview || game.nexusKullPreview ||
|
||||
!game.expansions.includes('choam') || !players.some(player => player.faction === 'richese'))) return false;
|
||||
if (game.kullPreview || game.nexusKullPreview) {
|
||||
if (game.kullPreview !== true || game.nexusKullPreview !== true || game.semutaPreview ||
|
||||
game.expansions.length !== 2 || !game.expansions.includes('ix') || !game.expansions.includes('choam') ||
|
||||
!players.some(player => player.faction === 'choam') ||
|
||||
players.some(player => player.faction !== 'choam' &&
|
||||
!FACTIONS.some(faction => faction.id === player.faction && faction.expansion === 'base'))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
@@ -88,7 +123,7 @@ export function selectedNexusWithoutCardsProfile(game: NexusCoreConfiguration):
|
||||
!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 =>
|
||||
return basicNativeNexusCore(game) || game.expansions.some(expansion => !players.some(player =>
|
||||
FACTIONS.some(faction => faction.id === player.faction && faction.expansion === expansion)));
|
||||
}
|
||||
|
||||
@@ -100,11 +135,12 @@ export function nexusCoreProfile(game: NexusCoreConfiguration): boolean {
|
||||
|
||||
function nexusCoreRosterProfile(game: NexusCoreConfiguration, selectedDecks: boolean, discovery = false): boolean {
|
||||
const players = game.players;
|
||||
const basicNative = selectedDecks && (basicNativeNexusCore(game) || nexusKullCore(game));
|
||||
if (typeof game.advanced !== 'boolean' || (!discovery && !game.nexusCards) ||
|
||||
!players || players.length < (game.techTokens ? 3 : 2) || players.length > 6 ||
|
||||
(game.strongholdCards && !game.advanced) ||
|
||||
(!discovery && (game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker)) ||
|
||||
game.semutaPreview || game.advancedPreview || game.kullPreview || game.nexusKullPreview ||
|
||||
(game.semutaPreview || game.kullPreview || game.nexusKullPreview) && !basicNative || game.advancedPreview ||
|
||||
game.richeseBetrayalPreview ||
|
||||
(!game.nexusCards && (game.guildBetrayalPreview || game.nexusIxianReplacementPreview ||
|
||||
game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview)) ||
|
||||
|
||||
@@ -15,6 +15,7 @@ export type KullDefaultProfile = {
|
||||
discoveries?: unknown;
|
||||
discoveryStash?: unknown;
|
||||
greatMaker?: unknown;
|
||||
sandtrout?: unknown;
|
||||
homeworlds?: unknown;
|
||||
techTokens?: unknown;
|
||||
strongholdCards?: unknown;
|
||||
@@ -58,8 +59,18 @@ export function kullDefaultProfile(game: KullDefaultProfile): KullDefaultEligibi
|
||||
new Set(game.players.map(player => player.faction)).size !== game.players.length)
|
||||
return blocked('Automatic Kull needs one to six distinct factions, including CHOAM, with their selected decks.');
|
||||
const options = game.lobbyOptions, projected = game.lobbyModules;
|
||||
if (game.nexusCards || projected?.nexusCards) {
|
||||
if (game.expansions.length !== 2 ||
|
||||
!game.players.every(player => player.faction === 'choam' || classic.has(player.faction)) ||
|
||||
options?.leaderSkills || options?.ecazTreachery || projected?.leaderSkills ||
|
||||
projected?.ecazTreachery || projected?.discoveries || game.leaderSkills || game.ecazTreachery ||
|
||||
game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker ||
|
||||
game.homeworlds || game.techTokens || game.strongholdCards || game.sandtrout)
|
||||
return blocked('Kull with Nexus currently requires Basic CHOAM and classic factions, the Ix and CHOAM decks, and no other optional modules.');
|
||||
return { eligible: true, reason: null };
|
||||
}
|
||||
if (options?.leaderSkills || projected?.leaderSkills ||
|
||||
projected?.nexusCards || projected?.discoveries || game.leaderSkills || game.ecazTreachery || game.nexusCards ||
|
||||
projected?.discoveries || game.leaderSkills || game.ecazTreachery ||
|
||||
game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker ||
|
||||
game.homeworlds || game.strongholdCards ||
|
||||
(game.techTokens && (!basicKullTechRoster(game) || options?.ecazTreachery || projected?.ecazTreachery)))
|
||||
|
||||
+2
-2
@@ -5993,7 +5993,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
developmentStage: 'Verified',
|
||||
summary: 'In the explicit Nexus Kull preview, choose one actual Treachery fuel card at a Karama attempt; the CHOAM Nexus is spent when the declaration is accepted, even if Kull is prevented.',
|
||||
steps: [
|
||||
'This is the fresh local nexus-kull development profile, not a public expansion start or an upgrade to existing saves. It admits two to six ready seats with native CHOAM and otherwise classic factions in Basic or Advanced, physical CHOAM and Ix Treachery decks, and Nexus alone. Other optional modules, expansion factions, Semuta and Richese Betrayal stay outside its boundary. The older kull profile remains printed-only and has no Nexus cards.',
|
||||
'Fresh Basic CHOAM/classic public games with the CHOAM and Ix decks and Nexus alone enable this preview by default; the host may turn it off. Original Ix Sandtrout stays in the Spice deck. The separate local Advanced audit remains available. Other optional modules, expansion factions, Semuta and Richese Betrayal stay outside this profile; existing games are not converted.',
|
||||
'The CHOAM Nexus face says: Discard any Treachery Card to obtain a Worthless Card special effect of your choice. Basic uses the CHOAM phase-wide scope; new Advanced attempts use the supplied specific-effect scope with provisional phase expiry. Cunning changes the fuel identity, not the effect, target or timing. Neither text supplies a nested counter priority: distinct counters and BG-before-conversion custody are the recorded user-selected policy, not a publisher clarification.',
|
||||
'Wait for another player’s validated Karama attempt. There is no proactive Kull choice in the generic Nexus panel. The public event, attempted player and purpose are the same whether CHOAM holds CHOAM Nexus, another Nexus, no eligible fuel or no Nexus at all. Only CHOAM receives its private source-aware plays and blocking reasons; opponents do not see the cost list or reserved original card.',
|
||||
'Choose a cost through the shared selector and inspect that actual card before Use. Printed source requires a held printed Kull Wahad. Nexus source requires unallied native CHOAM holding the actual CHOAM Nexus in this profile and one uniquely held canonical Treachery Card: a weapon, defense, special, Karama or Worthless card can be fuel. Reserved or promise-incompatible fuel is unavailable. The card does not become a manufactured Kull, and Karama used as fuel does not activate its own power.',
|
||||
@@ -6235,7 +6235,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
summary: 'Fresh supported classic starts cancel one original native Harkonnen traitor call, return that physical card immediately and give Harkonnen a private replacement during Mentat Pause.',
|
||||
searchText: 'Harkonnen Nexus Betrayal revealed declared traitor ally caller beneficiary shuffle replacement Mentat neutral acknowledgement',
|
||||
steps: [
|
||||
'In supported Basic selected-deck Nexus starts that initialize this reaction, native expansion leaders and the Ix Cheap Hero Traitor can also be declared. The selected stock and Richese cache remain physical; an owed replacement waits through the Ix phase-opening acknowledgements before the original Mentat draw. Advanced selected-deck reactions remain unfinished and their original calls continue without this interruption.',
|
||||
'Fresh Basic Nexus starts with selected Ix and/or CHOAM decks and no other modules initialize this reaction even when every selected family is seated, including the supported Richese/Semuta start. Native expansion leaders and the Ix Cheap Hero Traitor can be declared. An owed replacement waits through the Ix phase-opening acknowledgements before the original Mentat draw. Advanced selected-deck reactions remain unfinished and their original calls continue without this interruption.',
|
||||
'Original standalone preview requires native Harkonnen, classic2–6 Basic/Advanced, base33 and Nexus alone. Fresh classic Nexus core initializes the same effect with base33 or selected36, optional14, original Tech3+ and Advanced Strongholds2+, alongside actual Guild/Ixian reactions. Public selection binds the census; real held-card effects and cleanup stay native. No played game is reflagged; other native/module envelopes remain separate.',
|
||||
'Nexus has no initial deal. An actual alliance-qualified closing Nexus must supply the unallied holder. An effect scenario needs at least three seats; two seats can continue ordinary play without inventing a card. Two simultaneous receivers need four actual seats.',
|
||||
'The photographed publisher face cancels a revealed Harkonnen traitor, shuffles that exact card back immediately, and gives Harkonnen one replacement during Mentat Pause. It is not a draw-now exchange, whole-battle ban or refund of an already resolved outcome. Native Cunning and absent-Harkonnen Secret Ally remain separate effects.',
|
||||
|
||||
@@ -160,6 +160,12 @@ export function createNexusKullNativeParent(kind: 'inspection' | 'sardaukar' | '
|
||||
if (game.decision?.kind === 'guildShipment')
|
||||
game = applyAction(game, game.decision.player, { type: 'decision', allow: true });
|
||||
game = allowNativeParent(game);
|
||||
while (game.pendingGuildBetrayal) {
|
||||
const seat = game.players.find(p => viewGame(game, p.id).guildBetrayalReaction?.canPass);
|
||||
if (!seat) throw new Error('The original Guild Betrayal requires an explicit acknowledgement.');
|
||||
const event = viewGame(game, seat.id).guildBetrayalReaction!.event;
|
||||
game = applyAction(game, seat.id, { type: 'guildBetrayalPass', event });
|
||||
}
|
||||
game = applyAction(game, f.actor, { type: 'move', from: 'arrakeen:10', territory: 'hagga_basin', sector: 12, amount: 1 });
|
||||
game = applyAction(game, f.actor, { type: 'endMovement', nexus: viewGame(game, f.actor).nexusGuildCunning!.offer!.event });
|
||||
}
|
||||
|
||||
@@ -45,13 +45,15 @@ void test('other optional modules block automatic entry from both engine and pub
|
||||
for (const option of [
|
||||
{ lobbyOptions: { leaderSkills: true } },
|
||||
{ lobbyModules: { leaderSkills: true } },
|
||||
{ lobbyModules: { nexusCards: true } }, { lobbyModules: { discoveries: true } },
|
||||
{ leaderSkills: {} }, { ecazTreachery: true }, { nexusCards: {} }, { discoveryEnabled: true },
|
||||
{ lobbyModules: { discoveries: true } },
|
||||
{ leaderSkills: {} }, { ecazTreachery: true }, { discoveryEnabled: true },
|
||||
{ discoveries: {} }, { discoveryStash: {} }, { greatMaker: {} },
|
||||
{ homeworlds: {} }, { techTokens: {} }, { strongholdCards: {} },
|
||||
]) {
|
||||
assert.equal(kullDefaultEligible({ ...lobby, ...option }), false);
|
||||
}
|
||||
assert.equal(kullDefaultEligible({ ...lobby, nexusCards: {} }), true);
|
||||
assert.equal(kullDefaultEligible({ ...lobby, lobbyModules: { nexusCards: true } }), true);
|
||||
assert.equal(kullDefaultEligible({ ...lobby,
|
||||
lobbyModules: { leaderSkills: false, ecazTreachery: false, nexusCards: false, discoveries: false },
|
||||
lobbyOptions: { leaderSkills: false, ecazTreachery: false } }), true);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { kullDefaultEligible, kullDefaultProfile, type KullDefaultProfile } from '../game/kull-default-profile';
|
||||
|
||||
const lobby: KullDefaultProfile = {
|
||||
status: 'lobby', advanced: false, expansions: ['choam', 'ix'],
|
||||
players: [{ faction: 'choam' }, { faction: 'atreides' }, { faction: 'guild' }],
|
||||
nexusCards: { cards: null, phase: null },
|
||||
};
|
||||
|
||||
void test('Basic CHOAM and classic Nexus lobby receives the same public Kull default quote', () => {
|
||||
assert.deepEqual(kullDefaultProfile(lobby), { eligible: true, reason: null });
|
||||
assert.equal(kullDefaultEligible({ ...lobby, players: [{ faction: 'choam' }] }), true);
|
||||
// GameView carries Nexus selection as a public boolean instead of Game state.
|
||||
assert.equal(kullDefaultEligible({ ...lobby, nexusCards: undefined, lobbyModules: { nexusCards: true } }), true);
|
||||
// The host's explicit opt-out belongs to the caller, not the eligibility quote.
|
||||
const optedOut = { leaderSkills: false, kullOptOut: true };
|
||||
assert.equal(kullDefaultEligible({ ...lobby, lobbyOptions: optedOut }), true);
|
||||
});
|
||||
|
||||
void test('Nexus Kull default retains its native roster and module boundaries', () => {
|
||||
const unsupported: Partial<KullDefaultProfile>[] = [
|
||||
{ advanced: true }, { status: 'playing' },
|
||||
{ expansions: ['choam'] }, { expansions: ['choam', 'ix', 'ecaz'] },
|
||||
{ players: [{ faction: 'choam' }, { faction: 'richese' }] },
|
||||
{ players: [{ faction: 'choam' }, { faction: 'ixians' }] },
|
||||
{ players: [{ faction: 'choam' }, { faction: 'atreides' }, { faction: 'atreides' }] },
|
||||
{ players: [{ faction: 'atreides' }, { faction: 'guild' }] },
|
||||
{ lobbyOptions: { leaderSkills: true } }, { lobbyOptions: { ecazTreachery: true } },
|
||||
{ lobbyModules: { nexusCards: true, ecazTreachery: true } },
|
||||
{ lobbyModules: { nexusCards: true, discoveries: true } },
|
||||
{ leaderSkills: {} }, { ecazTreachery: true }, { discoveryEnabled: true },
|
||||
{ discoveries: {} }, { discoveryStash: {} }, { greatMaker: {} }, { homeworlds: {} },
|
||||
{ techTokens: {} }, { strongholdCards: {} }, { sandtrout: {} },
|
||||
];
|
||||
for (const variant of unsupported) {
|
||||
const result = kullDefaultProfile({ ...lobby, ...variant });
|
||||
assert.equal(result.eligible, false, JSON.stringify(variant));
|
||||
assert.ok(result.reason, 'Unavailable lobbies need a readable reason');
|
||||
}
|
||||
});
|
||||
|
||||
void test('Nexus Kull eligibility depends on public roster and options, never hidden cards or spice', () => {
|
||||
const privateSeat = { faction: 'choam', get hand() { throw Error('private hand read'); },
|
||||
get spice() { throw Error('private spice read'); } };
|
||||
assert.equal(kullDefaultEligible({ ...lobby, players: [privateSeat, { faction: 'fremen' }] }), true);
|
||||
});
|
||||
@@ -45,8 +45,8 @@ void test('fresh admission accepts exactly native classic CHOAM, physical CHOAM/
|
||||
assert.equal(initialized.nexusKullPreview, true);
|
||||
assert.ok(initialized.deck.some(card => card.id === 'ix-kull-wahad'));
|
||||
assert.deepEqual(initialized.spiceDeck.map(card => JSON.stringify(card)).sort(),
|
||||
spiceDeck().map(card => JSON.stringify(card)).sort());
|
||||
assert.equal(initialized.spiceDeck.some(card => 'sandtrout' in card), false);
|
||||
spiceDeck(true).map(card => JSON.stringify(card)).sort());
|
||||
assert.equal(initialized.spiceDeck.filter(card => 'sandtrout' in card).length, 1);
|
||||
for (const corrupt of [
|
||||
(g: Game) => { g.expansions = ['choam']; },
|
||||
(g: Game) => { g.nexusCards = undefined; },
|
||||
@@ -312,7 +312,9 @@ void test('independent canonical descriptors and unique live custody govern fuel
|
||||
]) {
|
||||
const offer = reload(f.game); change(offer);
|
||||
const card = offer.players[0].hand[0].id;
|
||||
assert.equal(viewGame(offer, f.choam).kullReaction!.plays.some(play => play.card.id === card), false);
|
||||
// The now-enabled native Ixian replacement profile rejects the corrupt
|
||||
// physical inventory before any Kull view is projected.
|
||||
assert.throws(() => viewGame(offer, f.choam));
|
||||
reject(offer, f.choam, { type: 'kullDecision', event: f.event, source: 'nexus', card });
|
||||
const declared = applyAction(f.game, f.choam, { type: 'kullDecision', event: f.event, source: 'nexus', card: f.fuel });
|
||||
const corrupt = reload(declared); change(corrupt);
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {test} from 'node:test';
|
||||
import {applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game} from '../game/engine';
|
||||
import {TERRITORIES} from '../game/board';
|
||||
import {traitorDeck} from '../game/traitors';
|
||||
import {validateNexusTraitorSnapshot} from '../game/nexus-traitor-exchange';
|
||||
import {nextHarkonnenBetrayalNativeStep, settleHarkonnenBetrayalNativeCounters,
|
||||
} from './fixture-nexus-harkonnen-betrayal';
|
||||
import {nextRicheseStrongholdsNativeStep} from './fixture-richese-strongholds';
|
||||
|
||||
const act = (game:Game, actor:string, action:Action) => applyAction(game,actor,action);
|
||||
const player = (game:Game, id:string) => {const seat=game.players.find(p=>p.id===id);assert.ok(seat);return seat;};
|
||||
const step = (game:Game) => {
|
||||
if(game.pendingGuildBetrayal) {const frame=game.pendingGuildBetrayal;const actor=frame.required.find(id=>!frame.passed.includes(id));assert.ok(actor);return act(game,actor,{type:'guildBetrayalPass',event:frame.invoice.event});}
|
||||
const next=(!game.response && !game.phaseOpening && (game.decision?.kind.startsWith('richese') || game.richeseAuction && !game.richeseAuction.outcome)) ? nextRicheseStrongholdsNativeStep(game)! : nextHarkonnenBetrayalNativeStep(game); try {return act(game,next.actor,next.action);} catch (error) {throw Error(`Step ${game.turn}/${game.phase}/${game.decision?.kind} ${JSON.stringify(next)}: ${String(error)}`);}};
|
||||
function advance(game:Game, until:(game:Game)=>boolean, limit=1800):Game {
|
||||
for(let i=0;i<limit;i++) {if(until(game)) return game; assert.notEqual(game.status,'finished'); game=step(game);}
|
||||
throw Error(`Native expanded Nexus journey stalled at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
}
|
||||
function inventory(game:Game):string[] {
|
||||
return [...game.deck,...game.discard,...(game.richeseCache??[]),...(game.richeseRemoved??[]),
|
||||
...(game.auction?.cards.slice(game.auction.index)??[]),...game.players.flatMap(p=>p.hand)].map(card=>card.id).sort();
|
||||
}
|
||||
function traitors(game:Game):void {
|
||||
validateNexusTraitorSnapshot({reserve:game.traitorReserve!,players:game.players.map(({id,faction,traitors,faceDancers})=>({id,faction,traitors,...(faceDancers?{faceDancers}: {})}))},traitorDeck(game.players,game.expansions.includes('ix')));
|
||||
}
|
||||
/** Native public selected-deck start, original setup and second-turn Nexus
|
||||
* draw. Conserved unopened deck order, one held traitor and two physical
|
||||
* reserve positions are staged; battle selection, plans and call are real. */
|
||||
function fixture(mixed:boolean) {
|
||||
const faction = mixed ? 'ixians' : 'choam';
|
||||
let game=createGame('HARKSEATED',newPlayer('i',faction,faction),false,mixed ? ['ix','choam'] : ['choam']);
|
||||
for(const [id,faction] of [['h','harkonnen'],['r','richese'],['g','guild']] as const)
|
||||
joinGame(game,newPlayer(id,faction,faction));
|
||||
game=act(game,'i',{type:'moduleOption',module:'nexusCards',enabled:true});
|
||||
for(const seat of game.players) game=act(game,seat.id,{type:'ready'});
|
||||
game=act(game,'i',{type:'start'});
|
||||
assert.equal(game.nexusHarkonnenBetrayalPreview,true);
|
||||
if (!mixed) assert.equal(game.semutaPreview,true, 'Fresh CHOAM/Richese start retains Semuta');
|
||||
if (mixed) assert.equal(game.nexusIxianBetrayalPreview,true);
|
||||
assert.equal(game.guildBetrayalPreview,true);
|
||||
game=advance(game,state=>state.status==='playing');
|
||||
const physical=inventory(game);traitors(game);
|
||||
assert.ok(game.richeseCache?.length,'Native Richese retains its separate cache');
|
||||
assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card=>card===null));
|
||||
const identity=player(game,'i').leaders.find(leader=>!leader.dead)!.id;
|
||||
const h=player(game,'h');
|
||||
if(!h.traitors.includes(identity)) {
|
||||
const replacement=h.traitors[0];
|
||||
const owner=game.players.find(seat=>seat.id!=='h'&&seat.traitors.includes(identity));
|
||||
if(owner) owner.traitors[owner.traitors.indexOf(identity)]=replacement;
|
||||
else {
|
||||
const dancer=game.players.flatMap(seat=>seat.faceDancers??[]).find(card=>card.leader===identity);
|
||||
if(dancer) dancer.leader=replacement;
|
||||
else {const index=game.traitorReserve!.indexOf(identity);assert.ok(index>=0);game.traitorReserve![index]=replacement;}
|
||||
}
|
||||
h.traitors[0]=identity;
|
||||
}
|
||||
traitors(game);
|
||||
const nexus=game.nexusCards!.cards!;
|
||||
const nx=nexus.deck.indexOf('harkonnen');assert.ok(nx>=0);
|
||||
nexus.deck.unshift(nexus.deck.splice(nx,1)[0]);
|
||||
for(let pos=0;pos<2;pos++) {
|
||||
const index=game.spiceDeck.findIndex((card,i)=>i>=pos&&'territory'in card);assert.ok(index>=pos);
|
||||
game.spiceDeck.splice(pos,0,game.spiceDeck.splice(index,1)[0]);
|
||||
}
|
||||
game=advance(game,state=>state.turn===2&&state.phase===1&&!state.response&&!state.decision);
|
||||
const worm=game.spiceDeck.findIndex(card=>'worm'in card&&!card.greatMaker&&!card.suppressed);assert.ok(worm>=0);
|
||||
game.spiceDeck.unshift(game.spiceDeck.splice(worm,1)[0]);
|
||||
game=advance(game,state=>state.nexus&&!state.spiceWindow&&!state.spiceResolution&&!state.response&&!state.decision);
|
||||
game=act(game,'h',{type:'alliance',target:'r'});game=act(game,'r',{type:'alliance',target:'h'});
|
||||
game=advance(game,state=>state.nexusCards?.phase?.stage==='drawing');
|
||||
assert.ok(game.nexusCards!.phase!.eligible.includes('i'));
|
||||
game=act(game,'i',{type:'nexusCardChoice',turn:game.turn,card:null,choice:'draw',ownRedraws:0});
|
||||
assert.equal(game.nexusCards!.cards!.hands.i,'harkonnen');
|
||||
game=advance(game,state=>state.phase===5&&!state.response&&!state.decision&&!state.pendingGuildBetrayal);
|
||||
const territory=TERRITORIES.find(t=>t.type==='sand'&&!t.sectors.includes(game.storm)&&
|
||||
game.players.every(p=>!Object.keys(p.forces).some(key=>key.startsWith(`${t.id}:`))));assert.ok(territory);
|
||||
for(const id of ['h','i']) {const seat=player(game,id);assert.ok(seat.reserves>0);seat.reserves--;seat.forces[`${territory.id}:${territory.sectors[0]}`]=1;}
|
||||
game=advance(game,state=>state.phase===6&&!!state.active&&!state.phaseOpening&&!state.response&&!state.decision);
|
||||
const chooser=game.active!;assert.ok(['h','i'].includes(chooser), `chooser ${chooser}; battle ${JSON.stringify(game.battle)}; decision ${JSON.stringify(game.decision)}; pending ${JSON.stringify(game.pendingGuildBetrayal)}; armies ${JSON.stringify(game.players.map(p=>[p.id,p.forces]))}`);
|
||||
game=act(game,chooser,{type:'chooseBattle',territory:territory.id,target:chooser==='h'?'i':'h'});
|
||||
game=advance(game,state=>!!state.battle&&!state.response&&!state.decision&&!state.battle.preparation&&
|
||||
(!state.battle.preLeader||state.battle.preLeader.closed));
|
||||
for(const id of [game.battle!.attacker,game.battle!.defender]) {
|
||||
const leader=id==='i'?identity:player(game,id).leaders.find(l=>!l.dead&&!l.usedAt)!.id;
|
||||
game=act(game,id,{type:'battlePlan',dial:0,leader});
|
||||
}
|
||||
assert.equal(game.battle!.revealed,true);
|
||||
game=advance(game,state=>!state.response&&!state.decision);
|
||||
game=act(game,'h',{type:'traitorCall',call:true});
|
||||
game=settleHarkonnenBetrayalNativeCounters(game);
|
||||
const reaction=viewGame(game,'i').nexusHarkonnenBetrayalReaction;
|
||||
assert.ok(reaction,'The actual expansion-roster traitor call opens native Betrayal');
|
||||
assert.equal(reaction.canUse,true);assert.equal(reaction.identity,identity);
|
||||
assert.deepEqual(inventory(game),physical);traitors(game);
|
||||
return {game,event:reaction.event,identity,physical};
|
||||
}
|
||||
|
||||
for(const mixed of [false,true]) for(const use of [false,true]) void test(`Basic public ${mixed?'Ix/CHOAM':'CHOAM/Semuta'} seated-family Harkonnen Nexus Betrayal ${use?'Use':'Pass'}`,()=>{
|
||||
const f=fixture(mixed);let game=f.game;
|
||||
if(use) game=act(game,'i',{type:'nexusHarkonnenBetrayalUse',event:f.event});
|
||||
else {
|
||||
for(const id of game.pendingNexusHarkonnenBetrayal!.required)
|
||||
game=act(game,id,{type:'nexusHarkonnenBetrayalPass',event:f.event});
|
||||
}
|
||||
assert.equal(game.pendingNexusHarkonnenBetrayal,null);
|
||||
assert.equal(game.nexusCards!.cards!.hands.i,use?null:'harkonnen');
|
||||
assert.equal(game.battle!.traitorCalls.h,!use);
|
||||
assert.equal(player(game,'h').traitors.includes(f.identity),!use);
|
||||
assert.equal(game.pendingNexusHarkonnenReplacement?.status??null,use?'due':null);
|
||||
assert.deepEqual(inventory(game),f.physical);traitors(game);
|
||||
game=advance(game,state=>state.phase===8);
|
||||
if (mixed) {
|
||||
assert.equal(game.phaseOpening?.initialize,true, 'Ix opening precedes actual Mentat initialization');
|
||||
assert.equal(game.pendingNexusHarkonnenReplacement?.status??null,use?'due':null);
|
||||
}
|
||||
game=advance(game,state=>state.phase===8&&!state.pendingNexusHarkonnenReplacement&&!state.phaseOpening);
|
||||
assert.equal(game.lastBattleContext!.result,use?'normal':'traitor');
|
||||
assert.equal(game.nexusHarkonnenReplacementHistory!.length,use?1:0);
|
||||
assert.equal(player(game,'h').traitors.length,4);
|
||||
assert.deepEqual(inventory(game),f.physical);traitors(game);
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Game, type Action } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { takeKullCard } from './fixture-choam-kull';
|
||||
import { advanceSemutaNexusTrade, swapSemutaNexusTradeIdentity } from './fixture-semuta-nexus-trade';
|
||||
import { orderIxSpiceCards, beforeIxSpiceDraw } from './fixture-ix-spice-cards';
|
||||
|
||||
const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
|
||||
const player = (game: Game, id: string) => { const p = game.players.find(row => row.id === id); assert.ok(p); return p; };
|
||||
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.truthtrance;
|
||||
const physical = (game: Game) => [...game.deck, ...game.discard,
|
||||
...(game.auction?.cards.slice(game.auction.index) ?? []), ...game.players.flatMap(p => p.hand)]
|
||||
.map(card => card.id).sort();
|
||||
|
||||
/** Native public start and original drawn Nexus. Conserved unopened Spice order,
|
||||
* earned Nexus identity and physical card placements are explicitly staged. */
|
||||
function fixture() {
|
||||
let game = createGame('PUBLICNXKULL', newPlayer('c','CHOAM','choam'), false, ['choam','ix']);
|
||||
for (const [id,faction] of [['a','atreides'],['g','guild'],['h','harkonnen']] as const)
|
||||
joinGame(game,newPlayer(id,faction,faction));
|
||||
game = act(game,'c',{type:'moduleOption',module:'nexusCards',enabled:true});
|
||||
for (const p of game.players) game = act(game,p.id,{type:'ready'});
|
||||
game = act(game,'c',{type:'start'});
|
||||
assert.equal(game.kullPreview,true); assert.equal(game.nexusKullPreview,true);
|
||||
assert.equal(game.spiceDeck.filter(card => 'sandtrout' in card).length,1);
|
||||
game = advanceSemutaNexusTrade(game, state => state.status === 'playing');
|
||||
const inventory = physical(game);
|
||||
// Prevent incidental battles while retaining every physical counter.
|
||||
for (const p of game.players) {p.reserves += Object.values(p.forces).reduce((a,b)=>a+b,0); p.forces = {};}
|
||||
game = advanceSemutaNexusTrade(game, beforeIxSpiceDraw);
|
||||
orderIxSpiceCards(game,['land','land']);
|
||||
game = advanceSemutaNexusTrade(game, state => state.turn === 2 && beforeIxSpiceDraw(state));
|
||||
orderIxSpiceCards(game,['worm','land','land']);
|
||||
game = advanceSemutaNexusTrade(game, state => state.nexus && !state.spiceWindow && clean(state));
|
||||
game = act(game,'a',{type:'alliance',target:'h'}); game = act(game,'h',{type:'alliance',target:'a'});
|
||||
game = advanceSemutaNexusTrade(game, state => state.nexusCards?.phase?.stage === 'drawing' && clean(state));
|
||||
assert.ok(game.nexusCards!.phase!.eligible.includes('c'));
|
||||
game = act(game,'c',{type:'nexusCardChoice',turn:game.turn,card:null,choice:'draw',ownRedraws:0});
|
||||
swapSemutaNexusTradeIdentity(game,'c','choam');
|
||||
game = advanceSemutaNexusTrade(game, state => state.turn === 3 && beforeIxSpiceDraw(state));
|
||||
orderIxSpiceCards(game,['sandtrout','land','land']);
|
||||
game = advanceSemutaNexusTrade(game, state => state.phase === 5 && state.active === 'g' && clean(state));
|
||||
assert.equal(game.sandtrout,true); assert.equal(game.nexusCards!.cards!.hands.c,'choam');
|
||||
// Put actual original cards into hands; no card is created or deleted.
|
||||
for (const p of game.players) game.deck.push(...p.hand.splice(0));
|
||||
const printed = takeKullCard(game,'ix-kull-wahad'); player(game,'c').hand.push(printed);
|
||||
const fuel = takeKullCard(game,'ix-hunter-seeker'); player(game,'c').hand.push(fuel);
|
||||
const original = takeKullCard(game,'karama'); player(game,'g').hand.push(original);
|
||||
const counter = takeKullCard(game,'karama'); player(game,'h').hand.push(counter);
|
||||
assert.deepEqual(physical(game),inventory);
|
||||
game = act(game,'g',{type:'card',mode:'shipment',card:original.id,target:'g'});
|
||||
assert.ok(game.pendingKull); assert.equal(game.sandtrout,true);
|
||||
return {game,inventory,printed:printed.id,fuel:fuel.id,original:original.id,counter:counter.id,event:game.pendingKull.event};
|
||||
}
|
||||
|
||||
function passCounter(state: Game): Game {
|
||||
let game=state;
|
||||
while(game.pendingKull) {
|
||||
const seat=game.players.find(p=>{const controls=viewGame(game,p.id).responseControls;
|
||||
return controls && !controls.hasPassed && controls.cancelCards.length>0;});
|
||||
game=seat ? act(game,seat.id,{type:'passResponse'}) : normalizeAutomaticGame(game);
|
||||
}
|
||||
return game;
|
||||
}
|
||||
|
||||
for (const source of ['printed','nexus'] as const) void test(`public Basic Nexus Kull ${source} succeeds after actual Sandtrout`,()=>{
|
||||
const f=fixture();
|
||||
for(const difficulty of DIFFICULTIES) {
|
||||
const view=viewGame(f.game,'c');view.players.find(p=>p.id==='c')!.bot=difficulty;
|
||||
const choice=botActions(view).find(a=>a.type==='kullDecision');assert.ok(choice);
|
||||
assert.doesNotThrow(()=>act(f.game,'c',choice));
|
||||
}
|
||||
let game=act(f.game,'c',{type:'kullDecision',event:f.event,source,card:source==='printed'?f.printed:f.fuel});
|
||||
game=passCounter(game);
|
||||
assert.equal(game.pendingKull,null);assert.equal(game.karamaShipping,null);
|
||||
assert.equal(player(game,'g').hand.some(card=>card.id===f.original),true);
|
||||
assert.equal(game.discard.filter(card=>card.id===(source==='printed'?f.printed:f.fuel)).length,1);
|
||||
assert.equal(game.nexusCards!.cards!.hands.c,source==='printed'?'choam':null);
|
||||
assert.equal(game.sandtrout,true);assert.deepEqual(physical(game),f.inventory);
|
||||
});
|
||||
|
||||
void test('public Basic Nexus Kull decline and distinct counter resume the original Karama',()=>{
|
||||
const f=fixture();
|
||||
const declined=act(f.game,'c',{type:'kullDecision',event:f.event,decline:true});
|
||||
assert.equal(declined.pendingKull,null);assert.equal(declined.karamaShipping?.owner,'g');
|
||||
assert.equal(declined.nexusCards!.cards!.hands.c,'choam');
|
||||
let countered=act(f.game,'c',{type:'kullDecision',event:f.event,source:'nexus',card:f.fuel});
|
||||
countered=act(countered,'h',{type:'card',mode:'cancel',card:f.counter});
|
||||
assert.equal(countered.pendingKull,null);assert.equal(countered.karamaShipping?.owner,'g');
|
||||
assert.equal(player(countered,'c').hand.some(card=>card.id===f.fuel),true);
|
||||
assert.equal(countered.nexusCards!.cards!.hands.c,null);
|
||||
assert.deepEqual(physical(declined),f.inventory);assert.deepEqual(physical(countered),f.inventory);
|
||||
});
|
||||
@@ -81,11 +81,13 @@ void test('Basic Semuta bank purchase uses the supported native roster, not unre
|
||||
joinGame(game, newPlayer('g','Guild','guild'));
|
||||
game.nexusCards = {cards:null,phase:null}; game.semutaPreview = true;
|
||||
assert.equal(emperorNexusBankPurchaseModeSupported(game), true);
|
||||
const unusedIx = structuredClone(game); unusedIx.expansions.push('ix');
|
||||
assert.equal(emperorNexusBankPurchaseModeSupported(unusedIx), true);
|
||||
for (const change of [
|
||||
(g:Game) => { g.advanced = true; },
|
||||
(g:Game) => { g.homeworlds = {custody:null}; },
|
||||
(g:Game) => { g.discoveryEnabled = true; },
|
||||
(g:Game) => { g.expansions.push('ix'); },
|
||||
(g:Game) => { g.expansions.push('ix', 'ix'); },
|
||||
(g:Game) => { g.nexusCards = undefined; },
|
||||
(g:Game) => { joinGame(g,newPlayer('e','Emperor','emperor')); },
|
||||
]) {
|
||||
@@ -131,5 +133,11 @@ for (const claim of [false, true]) for (const use of [false, true]) void test(
|
||||
game = applyAction(game, 'r', use ? {type:'nexusEmperorPurchase',event:originalOffer.event} : {type:'decision',karama:false});
|
||||
assert.equal(semutaNexusTradePlayer(game,'r').spice,spice - (use ? 0 : 2));
|
||||
assert.equal(game.nexusCards!.cards!.hands.r,use ? null : 'emperor');
|
||||
// Paying retains the held Emperor Nexus, so the newly initialized neutral
|
||||
// Ixian Secret Ally purchase window must be passed before the lot closes.
|
||||
if (!use) {
|
||||
assert.ok(game.pendingNexusIxianReplacement);
|
||||
game = applyAction(game, 'r', {type:'nexusIxianReplacementPass',event:game.pendingNexusIxianReplacement.event});
|
||||
}
|
||||
assert.deepEqual(semutaNexusTradeInventory(game),original);
|
||||
});
|
||||
Reference in new issue
Block a user