Connect three-deck Nexus Kull and Ixian Semuta
Verify and publish checkpoint container / container (push) Successful in 10m42s
Verify and publish checkpoint container / container (push) Successful in 10m42s
This commit is contained in:
1 parent
e697d7f558
commit
a1b09c5b02
14 files changed
+643
-25
No files matched your search
@@ -248,8 +248,8 @@ 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.
|
||||
|
||||
Fresh Basic CHOAM games with Richese, classic or Ixian/Tleilaxu factions, `choam,ix`
|
||||
decks and Nexus alone enable the **CHOAM Nexus Cunning Kull** preview by default; the host can turn
|
||||
Fresh Basic CHOAM games with `choam,ix` decks, optionally the Ecaz deck, and
|
||||
Nexus alone 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:
|
||||
|
||||
@@ -17,6 +17,16 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Basic three-deck Nexus Kull and Ixian replacement — 30 October. Prototyped / Partial.**
|
||||
Fresh Basic Nexus games with all three selected decks retain Kull, including
|
||||
actual Ecaz/Moritani setup; opting out retains Semuta. Ixian Nexus Secret
|
||||
Ally's private purchased-card discard now offers Semuta before the one
|
||||
replacement draw and original sale continuation, including normal and cache
|
||||
sales and empty-deck recycling. Twelve related files pass 117 cases. Public
|
||||
journeys stage conserved cards, deck order, positions and earned Nexus identities. Advanced, other modules, wider
|
||||
nested sources, complete games and deployed acceptance remain open.
|
||||
[Scope](NEXUS_CARD_RULES.md#basic-three-deck-nexus-kull-and-ixian-replacement--30-october-2026).
|
||||
|
||||
**Basic Richese, Semuta and Nexus Kull — 30 October. Prototyped / Partial.**
|
||||
Fresh Basic CHOAM/Richese Nexus starts with the Ix and CHOAM decks retain
|
||||
printed and Nexus-fueled Kull alongside Semuta. Claim/pass preserves private
|
||||
|
||||
@@ -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 three-deck Nexus Kull and Ixian replacement — 30 October 2026
|
||||
|
||||
**Prototyped / Partial.** Fresh Basic Nexus games with all three expansion
|
||||
decks can retain the existing CHOAM Kull preview, including actual Ecaz and
|
||||
Moritani seats and the supported Richese/Semuta protocol. Native Ambassadors,
|
||||
Terror and Ixian Bidding keep their original setup and continuation. A host
|
||||
who turns Kull off retains Semuta in this selected-deck Nexus profile.
|
||||
|
||||
An unallied buyer's Ixian Nexus Secret Ally can now offer its privately
|
||||
discarded purchased card to Semuta before the replacement draw. The original
|
||||
Nexus cost and auction payment stay committed; claim or pass draws once and
|
||||
resumes the original sale. This ordering composes the existing printed effects
|
||||
and provisional Semuta timing; it is not a new publisher ruling. Public-start
|
||||
fixtures use actual setup, token placements, earned Nexus draws and auction
|
||||
actions, with conserved card order, custody, positions and drawn identities
|
||||
staged. Other modules, Advanced and wider nested sources, complete games and
|
||||
deployed acceptance remain open.
|
||||
|
||||
## Basic Richese, Semuta and Nexus Kull — 30 October 2026
|
||||
|
||||
**Prototyped / Partial.** Fresh Basic Nexus games with CHOAM, Richese and
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# Rule decision index
|
||||
|
||||
## Basic three-deck Nexus Kull and Ixian replacement — 30 October 2026
|
||||
|
||||
The existing Basic Kull preview composes with selected Ecaz/Moritani decks
|
||||
and native setup. Ixian Nexus Secret Ally's private purchased-card discard
|
||||
offers Semuta before the original replacement draw. The ordering is a
|
||||
provisional composition, not a new publisher ruling.
|
||||
[Scope](NEXUS_CARD_RULES.md#basic-three-deck-nexus-kull-and-ixian-replacement--30-october-2026).
|
||||
|
||||
## Basic Richese, Semuta and Nexus Kull — 30 October 2026
|
||||
|
||||
The existing provisional Kull and Semuta ordering composes with Nexus Cunning:
|
||||
|
||||
+81
-11
@@ -2074,6 +2074,11 @@ export type Game = {
|
||||
sale: NonNullable<Game['currentAuctionSale']>;
|
||||
auctionIndex: number | null;
|
||||
auctionEvent: string | null;
|
||||
}
|
||||
| {
|
||||
kind: 'nexusIxianReplacementDiscard';
|
||||
source: IxianReplacementSource;
|
||||
stateSignature: string;
|
||||
};
|
||||
} | null;
|
||||
ornithopter?: {
|
||||
@@ -8294,6 +8299,19 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
c.use.response.owner === frame.player && c.use.response.recipient === frame.owner &&
|
||||
JSON.stringify(c.resume.response) === JSON.stringify(c.use.response)),
|
||||
'The saved Kull discard has lost its original cost or suspended response.');
|
||||
} else if (continuation?.kind === 'nexusIxianReplacementDiscard') {
|
||||
const c = continuation, source = c.source, sale = g.currentAuctionSale, entry = batch.entries[0];
|
||||
requireRule(g.semutaPreview === true && !g.advanced && g.phase === 3 &&
|
||||
!g.pendingNexusIxianReplacement && !g.pendingNexusIxianSpecialPurchase &&
|
||||
!!g.nexusIxianReplacementCursor && !!g.nexusCards?.cards && g.nexusCards.cards.discard.includes('ixians') &&
|
||||
sale && sale.winner === source.buyer && sale.amount === source.amount && sale.free === source.free &&
|
||||
source.turn === g.turn && source.sequence === g.nexusIxianReplacementCursor.sequence &&
|
||||
batch.cause === 'nexusIxianReplacement' && batch.entries.length === 1 &&
|
||||
!entry.publicFace && entry.discardedBy === source.buyer &&
|
||||
JSON.stringify(entry.card) === JSON.stringify(source.card) &&
|
||||
c.stateSignature === nexusIxianReplacementDiscardSignature(g, c),
|
||||
'The Ixian Nexus discard lost its committed purchase or replacement.');
|
||||
nexusRule(() => closeIxianReplacementSource(source, 'use', source.card, g.nexusIxianReplacementCursor!));
|
||||
} else if (continuation?.kind === 'nullentropyDiscard') {
|
||||
const c = continuation;
|
||||
const owner = g.players.find((p) => p.id === c.player);
|
||||
@@ -9745,6 +9763,15 @@ function semutaOfferSupported(
|
||||
entries.length === 1 && entries[0].publicFace && entries[0].discardedBy === continuation.player &&
|
||||
entries[0].card.id === continuation.card && entries[0].card.effect === 'juiceOfSapho';
|
||||
}
|
||||
if (continuation.kind === 'nexusIxianReplacementDiscard') {
|
||||
const source = continuation.source, sale = g.currentAuctionSale;
|
||||
return g.semutaPreview === true && !g.advanced && !!g.nexusCards && g.phase === 3 &&
|
||||
!!sale && sale.winner === source.buyer && sale.amount === source.amount && sale.free === source.free &&
|
||||
!g.pendingNexusIxianReplacement && !g.pendingNexusIxianSpecialPurchase &&
|
||||
(!g.pendingTreacheryDiscard || !!g.pendingTreacheryDiscard.reaction) &&
|
||||
entries.length === 1 && !entries[0].publicFace && entries[0].discardedBy === source.buyer &&
|
||||
JSON.stringify(entries[0].card) === JSON.stringify(source.card);
|
||||
}
|
||||
if (continuation.kind === 'saphoArrivalOrderDiscard') {
|
||||
const order = g.pendingArrivalOverlap?.saphoOrder;
|
||||
return g.semutaPreview === true && bareBasicSemutaProfile(g) && g.phase === 5 &&
|
||||
@@ -10650,6 +10677,17 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
|
||||
g.phaseOpening = next.resume.phaseOpening;
|
||||
if (next.source === 'move') finishMovedGroup(g, next.movement!);
|
||||
else if (next.source === 'end') finishMovementTurn(g, next.flight.player);
|
||||
} else if (next.kind === 'nexusIxianReplacementDiscard') {
|
||||
const buyer = getPlayer(g, next.source.buyer), card = draw(g);
|
||||
requireRule(card && g.nexusIxianReplacementCursor && g.nexusIxianReplacementHistory,
|
||||
'The committed Ixian Nexus discard requires its original replacement draw.');
|
||||
buyer.hand.push(card);
|
||||
const closed = nexusRule(() => closeIxianReplacementSource(next.source,'use',card,g.nexusIxianReplacementCursor!));
|
||||
g.nexusIxianReplacementHistory.push(closed.receipt);
|
||||
g.nexusIxianReplacementCursor = closed.cursor;
|
||||
log(g,`${buyer.name} spent Ixian Nexus Secret Ally to discard the purchased Treachery Card and draw a private replacement.`,
|
||||
{faction:buyer.faction,name:'Nexus purchased-card replacement'});
|
||||
continueAuctionSale(g,next.source.free);
|
||||
} else if (next.kind === 'ixAllyCard') {
|
||||
const buyer = getPlayer(g, next.player);
|
||||
const card = draw(g);
|
||||
@@ -11222,14 +11260,15 @@ function publicSemutaDiscoveryProfile(g: Game): boolean {
|
||||
!g.homeworlds && !g.nexusCards && g.discoveryEnabled === true && !g.ecazTreachery &&
|
||||
!g.leaderSkills && !g.kullPreview && !g.nexusKullPreview && discoveryModuleProfile(g);
|
||||
}
|
||||
/** Fresh Basic CHOAM/optional Ix Nexus starts retain the native Semuta path. */
|
||||
/** Fresh Basic selected-deck Nexus starts retain the native Semuta path. */
|
||||
function publicSemutaNexusProfile(g: Game): boolean {
|
||||
return g.status === 'lobby' && g.advanced === false &&
|
||||
g.players.length >= 2 && g.players.length <= 6 &&
|
||||
g.expansions.includes('choam') && g.expansions.length <= 2 &&
|
||||
g.expansions.every(id => id === 'choam' || id === 'ix') && !!byFaction(g, 'richese') &&
|
||||
g.expansions.includes('choam') && g.expansions.length <= 3 &&
|
||||
g.expansions.every(id => id === 'choam' || id === 'ix' || id === 'ecaz') && !!byFaction(g, 'richese') &&
|
||||
g.players.every(p => p.faction === 'richese' || p.faction === 'choam' ||
|
||||
g.expansions.includes('ix') && (p.faction === 'ixians' || p.faction === 'tleilaxu') || CLASSIC_FACTIONS[p.faction] === true) &&
|
||||
g.expansions.includes('ix') && (p.faction === 'ixians' || p.faction === 'tleilaxu') ||
|
||||
g.expansions.includes('ecaz') && (p.faction === 'ecaz' || p.faction === 'moritani') || CLASSIC_FACTIONS[p.faction] === true) &&
|
||||
!!g.nexusCards && !g.homeworlds && !g.discoveryEnabled && !g.ecazTreachery && !g.leaderSkills &&
|
||||
!g.techTokens && !g.strongholdCards && !g.twoPlayerControllers && !g.shortAllianceVictory &&
|
||||
!g.coalitionPreview && !g.kullPreview && !g.nexusKullPreview;
|
||||
@@ -12974,6 +13013,17 @@ function ixianNexusReplacementParent(g: Game): string {
|
||||
purchaseIncome:g.pendingRichesePurchaseIncome } : {}),
|
||||
decision:g.decision,response:g.response,phaseOpening:g.phaseOpening});
|
||||
}
|
||||
type NexusIxianReplacementDiscardContinuation = Extract<
|
||||
NonNullable<Game['pendingTreacheryDiscard']>['continuation'], {kind:'nexusIxianReplacementDiscard'}
|
||||
>;
|
||||
function nexusIxianReplacementDiscardSignature(g: Game, c: NexusIxianReplacementDiscardContinuation): string {
|
||||
return JSON.stringify({source:c.source, turn:g.turn, phase:g.phase, active:g.active,
|
||||
auction:g.auction, sale:g.currentAuctionSale, richeseAuction:g.richeseAuction,
|
||||
richeseBidding:g.richeseBidding, richeseCache:g.richeseCache, richeseRemoved:g.richeseRemoved,
|
||||
deck:g.deck, discard:g.discard, nexus:g.nexusCards, cursor:g.nexusIxianReplacementCursor,
|
||||
history:g.nexusIxianReplacementHistory, aid:g.aid,
|
||||
players:g.players.map(p => ({id:p.id,faction:p.faction,ally:p.ally,spice:p.spice,hand:p.hand}))});
|
||||
}
|
||||
function ixianNexusReplacementContext(g: Game) {
|
||||
requireRule(g.pendingNexusIxianSpecialPurchase || g.auction || g.richeseAuction,
|
||||
'The Nexus purchased-card choice lost its original purchase.');
|
||||
@@ -13076,7 +13126,20 @@ function decideIxianNexusReplacement(g: Game,id: string,action: Action): void {
|
||||
// All original-source/custody checks precede either the singleton cost or RNG.
|
||||
g.nexusCards.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,id,g.players));
|
||||
const buyer = getPlayer(g,id);
|
||||
discard(g,buyer,source.card.id);
|
||||
const used = discard(g,buyer,source.card.id);
|
||||
if (g.semutaPreview === true && !g.advanced && !special) {
|
||||
g.pendingNexusIxianReplacement = null;
|
||||
g.decision = null;
|
||||
const continuation: NexusIxianReplacementDiscardContinuation = {
|
||||
kind:'nexusIxianReplacementDiscard', source:structuredClone(source), stateSignature:'',
|
||||
};
|
||||
const entries = [{card:used, discardedBy:id, publicFace:false}];
|
||||
if (semutaOfferSupported(g, continuation, entries)) {
|
||||
continuation.stateSignature = nexusIxianReplacementDiscardSignature(g, continuation);
|
||||
stageTreacheryDiscard(g, 'nexusIxianReplacement', entries, continuation);
|
||||
return;
|
||||
}
|
||||
}
|
||||
replacement = draw(g) ?? null;
|
||||
requireRule(replacement, 'The purchased-card discard must supply a replacement draw.');
|
||||
buyer.hand.push(replacement);
|
||||
@@ -13703,10 +13766,13 @@ export function initializeSemutaGameForAudit(state: Game, skills = false): Game
|
||||
const advancedEcaz = bareAdvancedEcazSemutaProfile(state);
|
||||
const selectedSkillsDiscovery = skills && state.discoveryEnabled === true && !state.homeworlds &&
|
||||
selectedDiscoveryWithoutCardsProfile(state);
|
||||
const basicSelectedNexus = !state.advanced && !skills && !!state.nexusCards && nexusCoreProfile(state) &&
|
||||
!state.homeworlds && !state.discoveryEnabled && !state.ecazTreachery && !state.leaderSkills &&
|
||||
!state.techTokens && !state.strongholdCards;
|
||||
requireRule(!advancedEcaz || !skills, 'Advanced Ecaz Semuta excludes Leader Skills and other optional modules.');
|
||||
requireRule(!state.semutaPreview &&
|
||||
state.expansions.includes('choam') &&
|
||||
(state.expansions.every(id => id === 'choam' || id === 'ix') || basicNative || advancedEcaz || selectedSkillsDiscovery) &&
|
||||
(state.expansions.every(id => id === 'choam' || id === 'ix') || basicNative || advancedEcaz || selectedSkillsDiscovery || basicSelectedNexus) &&
|
||||
state.players.some(p => p.faction === 'richese'),
|
||||
'Semuta needs a fresh Richese faction game: bare Basic selected native families, bare Advanced Ecaz/Richese, or CHOAM with optional Ix and supported original modules/skills; separate classic or Advanced Tleilax Homeworld occupation has no other modules. Other overlays remain excluded.');
|
||||
if (state.discoveryEnabled) requireRule(selectedSkillsDiscovery || (!basicNative && !skills && !state.homeworlds &&
|
||||
@@ -13751,17 +13817,19 @@ export function initializeNexusKullGameForAudit(state: Game): Game {
|
||||
!state.discoveryEnabled && !state.discoveries && !state.discoveryStash && !state.greatMaker &&
|
||||
!state.techTokens && !state.strongholdCards && !state.ecazTreachery &&
|
||||
state.nexusCards?.cards === null && state.nexusCards.phase === null &&
|
||||
typeof state.advanced === 'boolean' && state.expansions.length === 2 &&
|
||||
typeof state.advanced === 'boolean' && (state.expansions.length === 2 || !state.advanced && state.expansions.length === 3 && state.expansions.includes('ecaz')) &&
|
||||
state.expansions.includes('choam') && state.expansions.includes('ix') &&
|
||||
state.players.some(p => p.faction === 'choam') &&
|
||||
state.players.every(p => p.faction === 'choam' || faction(p.faction).expansion === 'base' ||
|
||||
!state.advanced && (p.faction === 'richese' || p.faction === 'ixians' || p.faction === 'tleilaxu')),
|
||||
'Nexus Kull requires a fresh CHOAM lobby with classic or Basic Richese/Ixian/Tleilaxu factions, CHOAM and Ix decks, and only Nexus Cards.');
|
||||
!state.advanced && (p.faction === 'richese' || p.faction === 'ixians' || p.faction === 'tleilaxu' ||
|
||||
state.expansions.includes('ecaz') && (p.faction === 'ecaz' || p.faction === 'moritani'))),
|
||||
'Nexus Kull requires a fresh supported CHOAM lobby with selected faction decks and only Nexus Cards.');
|
||||
requireFreshBaseRuntime(state);
|
||||
const g = initializeSetupGameForAudit(state, false, true, false, false, false, false, true);
|
||||
g.kullPreview = true;
|
||||
g.nexusKullPreview = true;
|
||||
if (!g.advanced && byFaction(g, 'richese')) g.semutaPreview = true;
|
||||
if (!g.advanced && byFaction(g, 'ecaz')) g.ecazOccupyPreview = true;
|
||||
return g;
|
||||
}
|
||||
/** Prototype-only Discovery setup. Public starts stay gated while remaining effects are connected. */
|
||||
@@ -20301,10 +20369,12 @@ function kullIntegrity(g: Game): void {
|
||||
try {
|
||||
requireRule(g.nexusKullPreview === undefined || g.nexusKullPreview === true &&
|
||||
g.kullPreview === true && !!g.nexusCards?.cards &&
|
||||
g.expansions.length === 2 && g.expansions.includes('choam') && g.expansions.includes('ix') &&
|
||||
(g.expansions.length === 2 || !g.advanced && g.expansions.length === 3 && g.expansions.includes('ecaz')) &&
|
||||
g.expansions.includes('choam') && g.expansions.includes('ix') &&
|
||||
g.players.some(p => p.faction === 'choam') &&
|
||||
g.players.every(p => p.faction === 'choam' || faction(p.faction).expansion === 'base' ||
|
||||
!g.advanced && (p.faction === 'richese' || p.faction === 'ixians' || p.faction === 'tleilaxu')) &&
|
||||
!g.advanced && (p.faction === 'richese' || p.faction === 'ixians' || p.faction === 'tleilaxu' ||
|
||||
g.expansions.includes('ecaz') && (p.faction === 'ecaz' || p.faction === 'moritani'))) &&
|
||||
!g.homeworlds && !g.leaderSkills && !g.discoveryEnabled && !g.discoveries &&
|
||||
!g.discoveryStash && !g.greatMaker &&
|
||||
!g.techTokens && !g.strongholdCards && !g.ecazTreachery &&
|
||||
|
||||
@@ -94,12 +94,12 @@ export function selectedDiscoverySkillsSemutaProfile(game: NexusCoreConfiguratio
|
||||
selectedDiscoveryWithoutCardsProfile({...game, semutaPreview: undefined});
|
||||
}
|
||||
|
||||
/** Fresh Basic E1/E2 Nexus without additional modules. Native reaction stamps
|
||||
/** Fresh Basic selected-deck 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.expansions.every(id => id === 'ix' || id === 'choam' || id === 'ecaz') ||
|
||||
game.homeworlds || game.leaderSkills || game.techTokens || game.strongholdCards ||
|
||||
game.ecazTreachery || game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker ||
|
||||
game.advancedPreview || game.richeseBetrayalPreview) return false;
|
||||
@@ -107,9 +107,10 @@ function basicNativeNexusCore(game: NexusCoreConfiguration): boolean {
|
||||
!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.expansions.length !== 2 || !game.expansions.includes('ix') || !game.expansions.includes('choam') ||
|
||||
(game.expansions.length !== 2 && game.expansions.length !== 3) || !game.expansions.includes('ix') || !game.expansions.includes('choam') ||
|
||||
!players.some(player => player.faction === 'choam') ||
|
||||
players.some(player => player.faction !== 'choam' && player.faction !== 'richese' && player.faction !== 'ixians' && player.faction !== 'tleilaxu' &&
|
||||
!(game.expansions.includes('ecaz') && (player.faction === 'ecaz' || player.faction === 'moritani')) &&
|
||||
!FACTIONS.some(faction => faction.id === player.faction && faction.expansion === 'base'))) return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -60,13 +60,11 @@ export function kullDefaultProfile(game: KullDefaultProfile): KullDefaultEligibi
|
||||
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 => choam.has(player.faction) || classic.has(player.faction) || ix.has(player.faction)) ||
|
||||
options?.leaderSkills || options?.ecazTreachery || projected?.leaderSkills ||
|
||||
if (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, Richese, Ixian/Tleilaxu or classic factions, the Ix and CHOAM decks, and no other optional modules.');
|
||||
return blocked('Kull with Nexus currently requires Basic rules, the Ix and CHOAM decks and no other optional modules; the Ecaz deck may also be selected.');
|
||||
return { eligible: true, reason: null };
|
||||
}
|
||||
if (options?.leaderSkills || projected?.leaderSkills ||
|
||||
|
||||
+2
-1
@@ -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: [
|
||||
'Fresh Basic CHOAM games with Richese, classic or Ixian/Tleilaxu factions, 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. Semuta remains active with Richese even when Kull is turned off. The separate local Advanced audit remains available; other optional modules and Richese Betrayal remain outside this profile.',
|
||||
'Fresh Basic CHOAM games with selected Ix and CHOAM decks and Nexus alone enable this preview by default, including selected Ecaz and Moritani factions with their deck; the host may turn it off. Original Ix Sandtrout stays in the Spice deck. Semuta remains active with Richese even when Kull is turned off. The separate local Advanced audit remains available; other optional modules and Richese Betrayal remain outside this profile.',
|
||||
'In this Basic Richese profile, a successful printed Kull or distinct Karama counter offers its public discard to Semuta. Successful Nexus-fueled Kull offers the actual private fuel only after the physical Nexus has been spent and the effect committed; claim or pass retains the original Karama continuation.',
|
||||
'The native Ixian Bidding counter remains bound while Kull resolves, then its original Ixian Nexus opportunity and auction proceed. A waiting Sandtrout does not disable an earned Emperor Nexus ordinary bank-auction benefit; other Sandtrout purchase sources retain their separate limits.',
|
||||
'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.',
|
||||
@@ -6167,6 +6167,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
summary: 'An unallied holder with Ixians absent may spend Ixian Nexus to replace the exact card just bought in a normal auction or supported native Richese sale, preserving original settlement and drawing the actual deck top privately.',
|
||||
searchText: 'Ixian Nexus Secret Ally purchased card replacement normal auction cache Black Market Karama full hand empty deck same card',
|
||||
steps: [
|
||||
'In supported Basic Richese/Semuta Nexus games, using Ixian Secret Ally offers the private purchased-card discard to Semuta before the replacement draw. Claim or pass preserves the paid sale and spent Nexus, then the buyer draws once; an empty deck uses the normal discard recycle.',
|
||||
'Original standalone entries admit classic/base33, Tleilaxu plus classics/Ix47, or Richese plus classics/CHOAM35 and cache, with Ixians absent. Fresh classic2–6 Basic/Advanced Nexus core initializes the same effect with base33 or selected36, optional14, original Tech3+ and Advanced Strongholds2+; independent cards are not required. Other native/module envelopes keep their bounds; no played game gains a new stamp.',
|
||||
'Nexus has no starting deal. A draw requires a qualifying closing Nexus with an alliance. A two-seat profile can continue its base game but cannot naturally create an unallied Secret Ally holder; actual replacement needs at least three seats, or four for two simultaneous unallied recipients.',
|
||||
'The photographed publisher face grants an independent Secret Ally operation when Ixians are absent. Remain unallied and hold the actual singleton Ixian Nexus. Native Ixian allied replacement is a different cancelable advantage and is not removed or used as a fallback. The adopted unofficial Advanced rulebook supplies relevant core rules, not this absent Nexus face or a new Nexus ruling.',
|
||||
|
||||
@@ -23,7 +23,7 @@ void test('Basic CHOAM and classic Nexus lobby receives the same public Kull def
|
||||
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'] },
|
||||
{ expansions: ['choam'] }, { expansions: ['choam', 'ix', 'ix'] },
|
||||
{ players: [{ faction: 'choam' }, { faction: 'atreides' }, { faction: 'atreides' }] },
|
||||
{ players: [{ faction: 'atreides' }, { faction: 'guild' }] },
|
||||
{ lobbyOptions: { leaderSkills: true } }, { lobbyOptions: { ecazTreachery: true } },
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { kullDefaultProfile, type KullDefaultProfile } from '../game/kull-default-profile';
|
||||
|
||||
const lobby: KullDefaultProfile = {
|
||||
status: 'lobby', advanced: false, expansions: ['ix', 'choam', 'ecaz'],
|
||||
players: [{faction:'choam'}, {faction:'richese'}, {faction:'atreides'}],
|
||||
nexusCards: {cards:null, phase:null},
|
||||
};
|
||||
|
||||
void test('Basic three-deck Nexus Kull quote accepts selected and unused native families', () => {
|
||||
for (const extra of [[], ['ecaz'], ['moritani'], ['ecaz','moritani'], ['ixians','ecaz','moritani']]) {
|
||||
const input = {...lobby, players:[...lobby.players, ...extra.map(faction => ({faction}))]};
|
||||
const original = structuredClone(input);
|
||||
assert.deepEqual(kullDefaultProfile(input), {eligible:true, reason:null});
|
||||
assert.deepEqual(input, original);
|
||||
assert.equal(kullDefaultProfile({...input, nexusCards:undefined,
|
||||
lobbyModules:{nexusCards:true}}).eligible, true);
|
||||
}
|
||||
// Eligibility describes the available option; the caller owns host opt-out.
|
||||
const options = {leaderSkills:false, kullOptOut:true};
|
||||
assert.equal(kullDefaultProfile({...lobby, lobbyOptions:options}).eligible, true);
|
||||
});
|
||||
|
||||
void test('Three-deck Nexus Kull quote preserves public roster and module restrictions', () => {
|
||||
const invalid: Partial<KullDefaultProfile>[] = [
|
||||
{advanced:true}, {status:'playing'},
|
||||
{expansions:['ix','choam','choam']}, {expansions:['ix','choam','unknown']},
|
||||
{expansions:['ix','choam'], players:[{faction:'choam'}, {faction:'ecaz'}]},
|
||||
{players:[{faction:'choam'}, {faction:'ecaz'}, {faction:'ecaz'}]},
|
||||
{players:[{faction:'ecaz'}, {faction:'richese'}]},
|
||||
{lobbyOptions:{ecazTreachery:true}}, {lobbyOptions:{leaderSkills:true}},
|
||||
{lobbyModules:{nexusCards:true, ecazTreachery:true}},
|
||||
{lobbyModules:{nexusCards:true, discoveries:true}},
|
||||
{homeworlds:{}}, {techTokens:{}}, {strongholdCards:{}}, {discoveryEnabled:true},
|
||||
{discoveries:{}}, {discoveryStash:{}}, {greatMaker:{}}, {sandtrout:{}},
|
||||
];
|
||||
for (const variant of invalid) {
|
||||
const result = kullDefaultProfile({...lobby, ...variant});
|
||||
assert.equal(result.eligible, false, JSON.stringify(variant));
|
||||
assert.ok(result.reason, 'Unavailable configurations need a readable reason');
|
||||
}
|
||||
});
|
||||
|
||||
void test('Three-deck Nexus Kull eligibility never reads private cards or funds', () => {
|
||||
const ecaz = {faction:'ecaz', get hand() {throw Error('private hand read');},
|
||||
get spice() {throw Error('private spice read');}};
|
||||
assert.equal(kullDefaultProfile({...lobby, players:[{faction:'choam'},ecaz]}).eligible, true);
|
||||
});
|
||||
@@ -22,7 +22,7 @@ void test('Basic Nexus Kull public quote admits Richese with either or both nati
|
||||
void test('Richese Nexus Kull retains independent Basic roster and module guards', () => {
|
||||
const invalid: Partial<KullDefaultProfile>[] = [
|
||||
{ advanced: true }, { status: 'playing' }, { expansions: ['choam'] },
|
||||
{ expansions: ['choam', 'ix', 'ecaz'] },
|
||||
{ expansions: ['choam', 'ix', 'ix'] },
|
||||
{ players: [{ faction: 'richese' }, { faction: 'atreides' }] },
|
||||
{ players: [{ faction: 'choam' }, { faction: 'richese' }, { faction: 'richese' }] },
|
||||
{ players: [{ faction: 'choam' }, { faction: 'richese' }, { faction: 'ecaz' }] },
|
||||
|
||||
@@ -210,7 +210,7 @@ void test('closed histories survive later card transfers and recycling but canno
|
||||
assert.throws(() => normalizeAutomaticGame(duplicated));
|
||||
});
|
||||
|
||||
void test('fresh initializer preserves admitted identities and fences classic-only modules and in-play retrofit',() => {
|
||||
void test('fresh initializer preserves admitted identities and fences unrelated modules and in-play retrofit',() => {
|
||||
let lobby = createGame('SAMEID',newPlayer('host','Atreides','atreides'),true,[]);
|
||||
joinGame(lobby,newPlayer('guest','Emperor','emperor'));
|
||||
joinGame(lobby,newPlayer('guild','Guild','guild'));
|
||||
@@ -226,7 +226,9 @@ void test('fresh initializer preserves admitted identities and fences classic-on
|
||||
assert.throws(() => initializeIxianNexusReplacementGameForAudit(fixture.game));
|
||||
const expansion = reload(lobby);
|
||||
expansion.expansions = ['ix'];
|
||||
assert.throws(() => initializeIxianNexusReplacementGameForAudit(expansion));
|
||||
const selected = initializeIxianNexusReplacementGameForAudit(expansion);
|
||||
assert.equal(selected.nexusIxianReplacementPreview,true);
|
||||
assert.deepEqual(selected.expansions,['ix']);
|
||||
const overlay = reload(lobby);
|
||||
overlay.ecazTreachery = true;
|
||||
assert.throws(() => initializeIxianNexusReplacementGameForAudit(overlay));
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { takeKullCard } from './fixture-choam-kull';
|
||||
import { nextHarkonnenBetrayalNativeStep } from './fixture-nexus-harkonnen-betrayal';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
import { swapSemutaNexusTradeIdentity } from './fixture-semuta-nexus-trade';
|
||||
|
||||
const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
|
||||
const seat = (game: Game, id: string) => { const player = game.players.find(row => row.id === id); assert.ok(player); return player; };
|
||||
const clean = (game: Game) => !game.response && !game.decision && !game.phaseOpening;
|
||||
function stock(game: Game): string[] {
|
||||
return [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
|
||||
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? []),
|
||||
...(game.ixAuction?.cards ?? []), ...game.players.flatMap(player => player.hand)].map(card => card.id).sort();
|
||||
}
|
||||
function advance(game: Game, until: (game: Game) => boolean): Game {
|
||||
let last: unknown;
|
||||
for (let n = 0; n < 1800; n++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') {
|
||||
const pending = game.pendingTreacheryDiscard, reaction = pending.reaction;
|
||||
assert.ok(reaction && reaction.stage === 'offer');
|
||||
const actor = game.players.find(player => !reaction.passed.includes(player.id)); assert.ok(actor);
|
||||
game = act(game, actor.id, {type:'semutaPass',event:pending.batch.event});
|
||||
continue;
|
||||
}
|
||||
if (game.pendingGuildBetrayal) {
|
||||
const pending = game.pendingGuildBetrayal, actor = pending.required.find(id => !pending.passed.includes(id)); assert.ok(actor);
|
||||
game = act(game, actor, {type:'guildBetrayalPass',event:pending.invoice.event});
|
||||
continue;
|
||||
}
|
||||
if (!game.response && !game.phaseOpening && game.decision?.kind === 'ecazPlacement') {
|
||||
const token = game.ecazAmbassadors!.tokens.find(row => row.effect === 'ecaz' && row.zone === 'supply');
|
||||
game = act(game, game.decision.player, token
|
||||
? {type:'decision',token:token.id,territory:'arrakeen'} : {type:'decision',decline:true});
|
||||
continue;
|
||||
}
|
||||
if (!game.response && !game.phaseOpening && game.decision?.kind === 'moritaniPlacement') {
|
||||
const token = game.moritaniTerror!.tokens.find(row => row.kind === 'robbery' && row.status === 'available');
|
||||
game = act(game, game.decision.player, token
|
||||
? {type:'decision',token:token.id,territory:'carthag'} : {type:'decision',decline:true});
|
||||
continue;
|
||||
}
|
||||
const next = !game.response && !game.phaseOpening &&
|
||||
(game.decision?.kind.startsWith('richese') || game.richeseAuction && !game.richeseAuction.outcome)
|
||||
? nextRicheseStrongholdsNativeStep(game)! : nextHarkonnenBetrayalNativeStep(game);
|
||||
last = next;
|
||||
try { game = act(game, next.actor, next.action); }
|
||||
catch (error) { throw Error(`Native ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}: ${JSON.stringify(next)}: ${String(error)}`); }
|
||||
}
|
||||
throw Error(`Native three-expansion Nexus Kull journey stalled: ${game.turn}/${game.phase} ${JSON.stringify({last,stormPending:game.stormPending,stormDials:game.stormDials,stormDialers:game.stormDialers,phaseOpening:game.phaseOpening,response:game.response,decision:game.decision,ready:game.ready})}`);
|
||||
}
|
||||
function orderSpice(game: Game, kinds: readonly ('land'|'worm')[]): void {
|
||||
for (const [position, kind] of kinds.entries()) {
|
||||
const index = game.spiceDeck.findIndex((card, i) => i >= position &&
|
||||
(kind === 'land' ? 'territory' in card : 'worm' in card && !card.greatMaker && !card.suppressed));
|
||||
assert.ok(index >= position); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Public three-expansion native setup, real alliance and two earned Nexus draws. Unopened
|
||||
* Spice order, identities of genuinely drawn Nexus cards, board positions and
|
||||
* physical Treachery holdings are staged while conserving every component. */
|
||||
function fixture(includeRichese = true) {
|
||||
let game = createGame('NXKULLE3', newPlayer('ix','Ixians','ixians'), false, ['ix','choam','ecaz']);
|
||||
const peers = [ ['m','moritani'], ['c','choam'], ['r',includeRichese ? 'richese' : 'guild'], ['a','atreides'], ['e','ecaz'] ] as const;
|
||||
for (const [id,faction] of peers)
|
||||
joinGame(game, newPlayer(id,faction,faction));
|
||||
game = act(game,'ix',{type:'moduleOption',module:'nexusCards',enabled:true});
|
||||
for (const player of game.players) game = act(game,player.id,{type:'ready'});
|
||||
game = act(game,'ix',{type:'start'});
|
||||
assert.equal(game.kullPreview,true); assert.equal(game.nexusKullPreview,true); assert.equal(game.semutaPreview === true,includeRichese);
|
||||
assert.equal(game.nexusIxianBetrayalPreview,true);
|
||||
game = advance(game,state => state.status === 'playing');
|
||||
assert.equal(game.ecazOccupyPreview,true);
|
||||
assert.equal(Object.values(seat(game,'e').forces).reduce((sum,n)=>sum+n,0),6);
|
||||
const original = stock(game);
|
||||
for (const player of game.players) {
|
||||
player.reserves += Object.values(player.forces).reduce((sum,n)=>sum+n,0); player.forces = {};
|
||||
if (player.elites) { player.elites.reserves += Object.values(player.elites.forces).reduce((sum,n)=>sum+n,0); player.elites.forces={}; }
|
||||
}
|
||||
orderSpice(game,['land','land']);
|
||||
game = advance(game,state=>state.turn===2 && state.phase===1 && clean(state));
|
||||
assert.ok(game.ecazAmbassadors!.tokens.some(row => row.effect === 'ecaz' && row.location === 'arrakeen'));
|
||||
assert.ok(game.moritaniTerror!.tokens.some(row => row.kind === 'robbery' && row.location === 'carthag'));
|
||||
orderSpice(game,['worm','land','land']);
|
||||
game = advance(game,state=>state.nexus && !state.spiceWindow && !state.spiceResolution && clean(state));
|
||||
game = act(game,'ix',{type:'alliance',target:'m'}); game = act(game,'m',{type:'alliance',target:'ix'});
|
||||
game = advance(game,state=>state.nexusCards?.phase?.stage==='drawing' && clean(state));
|
||||
for (const id of ['a','c']) {
|
||||
assert.ok(game.nexusCards!.phase!.eligible.includes(id));
|
||||
game = act(game,id,{type:'nexusCardChoice',turn:game.turn,card:null,choice:'draw',ownRedraws:0});
|
||||
swapSemutaNexusTradeIdentity(game,id,id==='a'?'ixians':'choam');
|
||||
}
|
||||
game = advance(game,state=>state.phase===2 && clean(state));
|
||||
for (const player of game.players) game.deck.push(...player.hand.splice(0));
|
||||
const originalKarama = takeKullCard(game,'karama'); seat(game,'e').hand.push(originalKarama);
|
||||
const counter = takeKullCard(game,'karama'); seat(game,'a').hand.push(counter);
|
||||
game = advance(game,state=>state.response?.kind==='ixAuction');
|
||||
assert.equal(game.ixAuction?.cards.length,0);
|
||||
// Stage conserved held cards at the open native response, after CHOAM's
|
||||
// earlier market opportunity cannot sell the intended physical Kull.
|
||||
const printed = takeKullCard(game,'ix-kull-wahad'); seat(game,'c').hand.push(printed);
|
||||
const fuel = takeKullCard(game,'ix-hunter-seeker'); seat(game,'c').hand.push(fuel);
|
||||
const semuta = includeRichese ? takeKullCard(game,SEMUTA_DRUG_ID) : null;
|
||||
if (semuta) seat(game,'r').hand.push(semuta);
|
||||
assert.deepEqual(stock(game),original);
|
||||
game = act(game,'e',{type:'card',mode:'cancel',card:originalKarama.id});
|
||||
assert.ok(game.pendingKull); assert.equal(game.pendingKull.resume.response?.kind,'ixAuction');
|
||||
return {game,original,printed:printed.id,fuel:fuel.id,karama:originalKarama.id,counter:counter.id,semuta:semuta?.id,event:game.pendingKull.event};
|
||||
}
|
||||
function passKullCounter(state: Game): Game {
|
||||
let game=state;
|
||||
while (game.pendingKull) {
|
||||
const actor = game.players.find(player => { const controls=viewGame(game,player.id).responseControls;
|
||||
return controls && !controls.hasPassed && controls.cancelCards.length > 0; });
|
||||
game = actor ? act(game,actor.id,{type:'passResponse'}) : normalizeAutomaticGame(game);
|
||||
}
|
||||
return game;
|
||||
}
|
||||
|
||||
function semutaReaction(state: Game, claim: boolean): Game {
|
||||
let game = state;
|
||||
const event = game.pendingTreacheryDiscard?.batch.event; assert.ok(event);
|
||||
if (claim) {
|
||||
game = act(game,'r',{type:'semutaCommit',event});
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
||||
game = act(game,'r',{type:'semutaSelect',event,card:game.pendingTreacheryDiscard.batch.entries[0].card.id});
|
||||
} else while (game.pendingTreacheryDiscard?.batch.event === event && game.pendingTreacheryDiscard.reaction?.stage === 'offer') {
|
||||
const reaction = game.pendingTreacheryDiscard.reaction;
|
||||
const actor = game.players.find(player => !reaction.passed.includes(player.id)); assert.ok(actor);
|
||||
game = act(game,actor.id,{type:'semutaPass',event});
|
||||
}
|
||||
assert.notEqual(game.pendingTreacheryDiscard?.batch.event,event);
|
||||
return game;
|
||||
}
|
||||
|
||||
for (const source of ['printed','nexus'] as const) for (const claim of [false,true]) void test(
|
||||
`Basic Ecaz/Moritani/Richese/Ixian Nexus auction survives ${source} Kull and Semuta ${claim?'claim':'pass'}`, () => {
|
||||
const f=fixture();
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view=viewGame(f.game,'c'); view.players.find(player=>player.id==='c')!.bot=difficulty;
|
||||
const choice=botActions(view).find(action=>action.type==='kullDecision'); assert.ok(choice);
|
||||
assert.doesNotThrow(()=>act(f.game,'c',choice));
|
||||
}
|
||||
assert.ok(viewGame(f.game,'c').kullReaction?.plays.some(play => play.source === source && play.card.id === (source === 'printed' ? f.printed : f.fuel)),
|
||||
`Missing ${source} option; hand: ${seat(f.game,'c').hand.map(card=>card.id).join(', ')}; discard: ${f.game.discard.map(card=>card.id).join(', ')}`);
|
||||
const cost = source === 'printed' ? f.printed : f.fuel;
|
||||
let game=act(f.game,'c',{type:'kullDecision',event:f.event,source,card:cost});
|
||||
game=passKullCounter(game);
|
||||
assert.equal(game.pendingKull,null);
|
||||
assert.equal(seat(game,'e').hand.some(card=>card.id===f.karama),true);
|
||||
assert.equal(game.pendingTreacheryDiscard?.batch.entries[0].card.id,cost);
|
||||
assert.equal(game.pendingTreacheryDiscard?.batch.entries[0].publicFace,source === 'printed');
|
||||
game=semutaReaction(game,claim);
|
||||
assert.equal(seat(game,'r').hand.some(card=>card.id===cost),claim);
|
||||
assert.equal(game.nexusCards!.cards!.hands.c,source === 'printed' ? 'choam' : null);
|
||||
game=advance(game,state=>!!state.pendingNexusIxianBetrayal);
|
||||
const nexus=game.pendingNexusIxianBetrayal!;
|
||||
assert.equal(viewGame(game,'a').nexusIxianBetrayalReaction?.canUse,true);
|
||||
for (const use of [false,true]) {
|
||||
let resumed = structuredClone(game);
|
||||
if (use) resumed=act(resumed,'a',{type:'nexusIxianBetrayalUse',event:nexus.source.event});
|
||||
else while (resumed.pendingNexusIxianBetrayal) {
|
||||
const pending=resumed.pendingNexusIxianBetrayal;
|
||||
const actor=pending.required.find(id=>!pending.passed.includes(id)); assert.ok(actor);
|
||||
resumed=act(resumed,actor,{type:'nexusIxianBetrayalPass',event:pending.source.event});
|
||||
}
|
||||
assert.equal(resumed.pendingNexusIxianBetrayal,null);
|
||||
if (!use) {
|
||||
assert.equal(resumed.decision?.kind,'ixAuction'); assert.ok(resumed.ixAuction?.cards.length);
|
||||
resumed=act(resumed,'ix',{type:'decision',card:resumed.ixAuction.cards[0].id,position:'top'});
|
||||
}
|
||||
assert.equal(resumed.ixAuction,null); assert.ok(resumed.auction);
|
||||
assert.deepEqual(stock(resumed),f.original);
|
||||
}
|
||||
});
|
||||
|
||||
for (const claim of [false,true]) void test(
|
||||
`Basic Ecaz/Moritani/Richese/Ixian Nexus auction resumes original cancellation after Kull counter and Semuta ${claim?'claim':'pass'}`, () => {
|
||||
const f=fixture();
|
||||
let game=act(f.game,'c',{type:'kullDecision',event:f.event,source:'nexus',card:f.fuel});
|
||||
game=act(game,'a',{type:'card',mode:'cancel',card:f.counter});
|
||||
assert.equal(game.pendingTreacheryDiscard?.batch.entries[0].card.id,f.counter);
|
||||
game=semutaReaction(game,claim);
|
||||
if (game.pendingTreacheryDiscard) {
|
||||
assert.equal(game.pendingTreacheryDiscard.batch.entries[0].card.id,f.karama);
|
||||
game=semutaReaction(game,false);
|
||||
}
|
||||
assert.equal(game.pendingKull,null); assert.equal(game.pendingNexusIxianBetrayal,null);
|
||||
assert.equal(game.ixAuction,null); assert.ok(game.auction);
|
||||
assert.equal(game.discard.some(card=>card.id===f.karama),true);
|
||||
assert.equal(seat(game,'c').hand.some(card=>card.id===f.fuel),true);
|
||||
assert.equal(game.nexusCards!.cards!.hands.c,null);
|
||||
assert.deepEqual(stock(game),f.original);
|
||||
});
|
||||
|
||||
for (const source of ['printed','nexus'] as const) void test(
|
||||
`Basic three-expansion Nexus auction retains ${source} Kull without Richese`, () => {
|
||||
const f = fixture(false), cost = source === 'printed' ? f.printed : f.fuel;
|
||||
let game = act(f.game,'c',{type:'kullDecision',event:f.event,source,card:cost});
|
||||
game = passKullCounter(game);
|
||||
assert.equal(game.pendingKull,null);
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null,null);
|
||||
assert.equal(game.discard.filter(card=>card.id===cost).length,1);
|
||||
assert.equal(game.nexusCards!.cards!.hands.c,source === 'printed' ? 'choam' : null);
|
||||
game = advance(game,state=>!!state.pendingNexusIxianBetrayal);
|
||||
const pending = game.pendingNexusIxianBetrayal!;
|
||||
game = act(game,'a',{type:'nexusIxianBetrayalUse',event:pending.source.event});
|
||||
assert.equal(game.ixAuction,null);
|
||||
assert.ok(game.auction);
|
||||
assert.deepEqual(stock(game),f.original);
|
||||
});
|
||||
|
||||
void test('Basic three-expansion Nexus keeps Semuta when the host opts out of Kull', () => {
|
||||
let game = createGame('NXE3OPTOUT',newPlayer('ix','Ixians','ixians'),false,['ix','choam','ecaz']);
|
||||
for (const [id,faction] of [['m','moritani'],['c','choam'],['r','richese'],['a','atreides'],['e','ecaz']] as const)
|
||||
joinGame(game,newPlayer(id,faction,faction));
|
||||
game = act(game,'ix',{type:'moduleOption',module:'nexusCards',enabled:true});
|
||||
game = act(game,'ix',{type:'moduleOption',module:'kullPreview',enabled:false});
|
||||
for (const player of game.players) game = act(game,player.id,{type:'ready'});
|
||||
game = act(game,'ix',{type:'start'});
|
||||
assert.equal(game.semutaPreview,true);
|
||||
assert.notEqual(game.kullPreview,true);
|
||||
assert.notEqual(game.nexusKullPreview,true);
|
||||
game = advance(game,state=>state.status==='playing');
|
||||
assert.equal(game.ecazOccupyPreview,true);
|
||||
assert.equal(Object.values(seat(game,'e').forces).reduce((sum,n)=>sum+n,0),6);
|
||||
const original = stock(game);
|
||||
// Avoid unrelated battles while retaining every physical counter.
|
||||
for (const player of game.players) {
|
||||
player.reserves += Object.values(player.forces).reduce((sum,n)=>sum+n,0); player.forces={};
|
||||
if (player.elites) {player.elites.reserves += Object.values(player.elites.forces).reduce((sum,n)=>sum+n,0); player.elites.forces={};}
|
||||
}
|
||||
orderSpice(game,['land','land']);
|
||||
game = advance(game,state=>state.turn===2 && state.phase===1 && clean(state));
|
||||
assert.ok(game.ecazAmbassadors!.tokens.some(row=>row.effect==='ecaz' && row.location==='arrakeen'));
|
||||
assert.ok(game.moritaniTerror!.tokens.some(row=>row.kind==='robbery' && row.location==='carthag'));
|
||||
orderSpice(game,['land','land']);
|
||||
game = advance(game,state=>state.phase===5 && !!state.active && state.active!=='r' && clean(state));
|
||||
const actor = game.active!;
|
||||
// Move existing cards, preserving the original physical stock.
|
||||
for (const player of game.players) game.deck.push(...player.hand.splice(0));
|
||||
const hajr = takeKullCard(game,'hajr'); seat(game,actor).hand.push(hajr);
|
||||
const semuta = takeKullCard(game,SEMUTA_DRUG_ID); seat(game,'r').hand.push(semuta);
|
||||
assert.deepEqual(stock(game),original);
|
||||
const pending = act(game,actor,{type:'card',card:hajr.id});
|
||||
assert.equal(pending.pendingTreacheryDiscard?.batch.cause,'ordinary:hajr');
|
||||
assert.equal(viewGame(pending,'r').semutaReaction?.canCommit,true);
|
||||
for (const claim of [false,true]) {
|
||||
const resumed = semutaReaction(structuredClone(pending),claim);
|
||||
assert.equal(resumed.pendingTreacheryDiscard,null);
|
||||
assert.ok(resumed.hajr.includes(actor));
|
||||
assert.equal(seat(resumed,'r').hand.some(card=>card.id===hajr.id),claim);
|
||||
assert.equal(resumed.discard.some(card=>card.id===semuta.id),claim);
|
||||
assert.deepEqual(stock(resumed),original);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
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 { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { advanceSemutaNexusTrade, nextSemutaNexusTradeNativeStep, swapSemutaNexusTradeIdentity } from './fixture-semuta-nexus-trade';
|
||||
|
||||
const act = (game: Game, id: string, action: Action) => applyAction(game, id, action);
|
||||
const seat = (game: Game, id: string) => { const player = game.players.find(row => row.id === id); assert.ok(player); return player; };
|
||||
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.truthtrance &&
|
||||
!game.pendingGuildBetrayal && !game.pendingTreacheryDiscard;
|
||||
function inventory(game: Game): string[] {
|
||||
const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
|
||||
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? []),
|
||||
...(game.ixAuction?.cards ?? []), ...game.players.flatMap(player => player.hand)].map(card => card.id).sort();
|
||||
assert.equal(new Set(cards).size, cards.length, 'Each physical Treachery card has one live location');
|
||||
return cards;
|
||||
}
|
||||
|
||||
/** Public Basic setup and a genuine qualifying Nexus draw. Original unopened
|
||||
* Spice/Nexus order, board counters and card custody are staged conservatively;
|
||||
* the alliance, bidding, payment and purchased-card response are real actions. */
|
||||
function fixture(exhaustDeck = false, cacheSale = false) {
|
||||
let game = createGame('SEMUTANEXUSIX', newPlayer('a','Atreides','atreides'), false, ['choam']);
|
||||
for (const [id, faction] of [['r','richese'], ['e','emperor'], ['g','guild']] as const)
|
||||
joinGame(game, newPlayer(id, faction, faction));
|
||||
game = act(game, 'a', {type:'moduleOption', module:'nexusCards', enabled:true});
|
||||
for (const player of game.players) game = act(game, player.id, {type:'ready'});
|
||||
game = act(game, 'a', {type:'start'});
|
||||
assert.equal(game.semutaPreview, true); assert.equal(game.nexusIxianReplacementPreview, true);
|
||||
game = advanceSemutaNexusTrade(game, state => state.status === 'playing');
|
||||
const original = inventory(game);
|
||||
for (const player of game.players) {
|
||||
player.reserves += Object.values(player.forces).reduce((sum, count) => sum + count, 0); player.forces = {};
|
||||
if (player.elites) { player.elites.reserves += Object.values(player.elites.forces).reduce((sum, count) => sum + count, 0); player.elites.forces = {}; }
|
||||
}
|
||||
for (let position = 0; position < 2; position++) {
|
||||
const index = game.spiceDeck.findIndex((card, n) => n >= position && 'territory' in card); assert.ok(index >= position);
|
||||
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
game = advanceSemutaNexusTrade(game, state => state.turn === 2 && state.phase === 1 && clean(state));
|
||||
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 = advanceSemutaNexusTrade(game, state => !!state.nexus && !state.spiceWindow && clean(state));
|
||||
game = act(game,'e',{type:'alliance', target:'g'}); game = act(game,'g',{type:'alliance', target:'e'});
|
||||
game = advanceSemutaNexusTrade(game, state => state.nexusCards?.phase?.stage === 'drawing' && clean(state));
|
||||
assert.ok(game.nexusCards!.phase!.eligible.includes('a'));
|
||||
game = act(game,'a',{type:'nexusCardChoice', turn:game.turn, card:null, choice:'draw', ownRedraws:0});
|
||||
swapSemutaNexusTradeIdentity(game,'a','ixians');
|
||||
// Move the physical Semuta to Richese, exchanging one existing card if held.
|
||||
const richese = seat(game,'r'), zones = [game.deck, game.discard, game.richeseCache!, ...game.players.map(player => player.hand)];
|
||||
const source = zones.find(cards => cards.some(card => card.id === SEMUTA_DRUG_ID)); assert.ok(source);
|
||||
if (source !== richese.hand) {
|
||||
const index = source.findIndex(card => card.id === SEMUTA_DRUG_ID), [semuta] = source.splice(index,1);
|
||||
const displaced = richese.hand.pop(); if (displaced) source.push(displaced);
|
||||
richese.hand.push(semuta);
|
||||
}
|
||||
// A real held Karama keeps the native paid-or-free auction decision open.
|
||||
const buyer = seat(game,'a');
|
||||
if (!buyer.hand.some(card => card.effect === 'karama')) {
|
||||
const holders = [game.deck, game.discard, game.richeseCache!, ...game.players.filter(player => player.id !== 'a').map(player => player.hand)];
|
||||
const cards = holders.find(zone => zone.some(card => card.effect === 'karama')); assert.ok(cards);
|
||||
const index = cards.findIndex(card => card.effect === 'karama'), [karama] = cards.splice(index,1);
|
||||
const displaced = buyer.hand.pop(); if (displaced) cards.push(displaced);
|
||||
buyer.hand.push(karama);
|
||||
}
|
||||
assert.deepEqual(inventory(game), original);
|
||||
if (cacheSale) {
|
||||
game = advanceSemutaNexusTrade(game,state => state.decision?.kind === 'richeseCache');
|
||||
const lot = game.richeseCache!.find(card => card.id !== SEMUTA_DRUG_ID); assert.ok(lot);
|
||||
const bought = structuredClone(lot), beforeSpice = seat(game,'a').spice;
|
||||
game = act(game,'r',{type:'decision', event:game.richeseBidding!.event,
|
||||
card:bought.id, method:'onceAround', direction:'clockwise'});
|
||||
let bid = false;
|
||||
for (let n = 0; !game.pendingNexusIxianReplacement && n < 150; n++) {
|
||||
if (game.richeseAuction && !game.richeseAuction.outcome && !game.decision && !game.response && game.richeseAuction.active) {
|
||||
const actor = game.richeseAuction.active;
|
||||
const ownBid = actor === 'a' && !bid;
|
||||
game = act(game,actor,{type:'richeseBid',event:game.richeseAuction.event,
|
||||
amount:ownBid ? 2 : null, ...(ownBid ? {allyPayment:0} : {})});
|
||||
if (ownBid) bid = true;
|
||||
} else {
|
||||
const next = nextSemutaNexusTradeNativeStep(game);
|
||||
game = act(game,next.actor,next.action);
|
||||
}
|
||||
}
|
||||
assert.ok(bid); assert.ok(game.pendingNexusIxianReplacement);
|
||||
assert.equal(game.currentAuctionSale?.origin,'cache');
|
||||
assert.equal(game.pendingNexusIxianReplacement!.card.id,bought.id);
|
||||
assert.equal(seat(game,'a').spice,beforeSpice - 2);
|
||||
assert.ok(game.deck[0]);
|
||||
return {game, original, purchased:bought, replacement:structuredClone(game.deck[0]), beforeSpice};
|
||||
}
|
||||
game = advanceSemutaNexusTrade(game, state => state.phase === 3 && !!state.auction && clean(state));
|
||||
while (game.auction!.active !== 'a') game = act(game, game.auction!.active, {type:'passBid'});
|
||||
game = act(game,'a',{type:'bid', amount:2, allyPayment:0});
|
||||
game = advanceSemutaNexusTrade(game, state => state.decision?.kind === 'auctionPayment' && state.decision.player === 'a');
|
||||
const purchased = structuredClone(game.auction!.cards[game.auction!.index]), beforeSpice = seat(game,'a').spice;
|
||||
// An empty draw deck uses the real discard recycling path. Stage only
|
||||
// original, unallocated cards before the purchase source is committed.
|
||||
if (exhaustDeck) game.discard.push(...game.deck.splice(0));
|
||||
game = act(game,'a',{type:'decision',karama:false});
|
||||
game = advanceSemutaNexusTrade(game, state => !!state.pendingNexusIxianReplacement);
|
||||
assert.equal(seat(game,'a').spice, beforeSpice - 2);
|
||||
assert.equal(game.pendingNexusIxianReplacement!.card.id,purchased.id);
|
||||
assert.equal(game.deck.length === 0,exhaustDeck);
|
||||
return {game, original, purchased, replacement:game.deck[0] ? structuredClone(game.deck[0]) : null, beforeSpice};
|
||||
}
|
||||
|
||||
for (const claim of [false,true]) void test(`Basic public Ixian Nexus purchased-card discard offers Semuta ${claim ? 'claim' : 'pass'} before replacement`, () => {
|
||||
const f = fixture(), before = structuredClone(f.game), bought = f.purchased.id;
|
||||
assert.ok(f.replacement);
|
||||
const pending = act(f.game,'a',{type:'nexusIxianReplacementUse',event:f.game.pendingNexusIxianReplacement!.event});
|
||||
assert.deepEqual(f.game,before, 'Original input remains unchanged');
|
||||
assert.equal(pending.nexusCards!.cards!.hands.a,null);
|
||||
assert.equal(pending.nexusCards!.cards!.discard.filter(face => face === 'ixians').length,1);
|
||||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage,'offer');
|
||||
assert.equal(pending.pendingTreacheryDiscard!.batch.entries.length,1);
|
||||
assert.equal(pending.pendingTreacheryDiscard!.batch.entries[0].card.id,bought);
|
||||
assert.equal(pending.pendingTreacheryDiscard!.batch.entries[0].publicFace,false);
|
||||
assert.equal(pending.pendingTreacheryDiscard!.batch.entries[0].discardedBy,'a');
|
||||
assert.deepEqual(pending.deck,before.deck, 'Replacement draw waits for the fresh discard response');
|
||||
assert.deepEqual(viewGame(pending,'r').semutaReaction!.candidates,[]);
|
||||
assert.deepEqual(viewGame(pending,'g').semutaReaction!.candidates,[]);
|
||||
const event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(pending,'r'); view.players.find(player => player.id === 'r')!.bot = difficulty;
|
||||
const action = botActions(view).find(row => row.type === 'semutaCommit' || row.type === 'semutaPass'); assert.ok(action);
|
||||
assert.doesNotThrow(() => act(pending,'r',action));
|
||||
}
|
||||
let game = pending;
|
||||
if (claim) game = act(game,'r',{type:'semutaCommit',event});
|
||||
else for (const player of game.players) game = act(game,player.id,{type:'semutaPass',event});
|
||||
assert.equal(game.pendingTreacheryDiscard,null);
|
||||
assert.equal(game.pendingNexusIxianReplacement,null);
|
||||
assert.equal(seat(game,'a').spice,f.beforeSpice-2);
|
||||
assert.equal(seat(game,'a').hand.some(card => card.id === f.replacement!.id),true);
|
||||
assert.equal(seat(game,'r').hand.some(card => card.id === bought),claim);
|
||||
assert.equal(game.discard.some(card => card.id === bought),!claim);
|
||||
assert.equal(game.discard.some(card => card.id === SEMUTA_DRUG_ID),claim);
|
||||
assert.equal(game.nexusIxianReplacementHistory!.length,1);
|
||||
assert.equal(game.nexusIxianReplacementHistory![0].source.card.id,bought);
|
||||
assert.equal(game.nexusIxianReplacementHistory![0].replacement!.id,f.replacement.id);
|
||||
assert.deepEqual(game.deck.map(card => card.id),before.deck.slice(1).map(card => card.id));
|
||||
assert.deepEqual(inventory(game),f.original);
|
||||
});
|
||||
|
||||
for (const claim of [false,true]) void test(`Basic Richese cache sale offers its Ixian Nexus discard to Semuta ${claim ? 'claim' : 'pass'}`, () => {
|
||||
const f = fixture(false,true), before = structuredClone(f.game), bought = f.purchased.id;
|
||||
assert.ok(f.replacement); assert.equal(before.currentAuctionSale?.origin,'cache');
|
||||
const sellerSpice = seat(before,'r').spice;
|
||||
const pending = act(f.game,'a',{type:'nexusIxianReplacementUse',event:f.game.pendingNexusIxianReplacement!.event});
|
||||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage,'offer');
|
||||
assert.equal(pending.pendingTreacheryDiscard!.batch.entries[0].card.id,bought);
|
||||
assert.equal(pending.pendingTreacheryDiscard!.batch.entries[0].publicFace,false);
|
||||
assert.deepEqual(pending.deck,before.deck);
|
||||
assert.equal(pending.nexusCards!.cards!.hands.a,null);
|
||||
assert.equal(seat(pending,'r').spice,sellerSpice, 'The original seller receipt waits for the replacement');
|
||||
const event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
let game = pending;
|
||||
if (claim) game = act(game,'r',{type:'semutaCommit',event});
|
||||
else for (const player of game.players) game = act(game,player.id,{type:'semutaPass',event});
|
||||
assert.equal(game.pendingTreacheryDiscard,null);
|
||||
assert.equal(game.pendingNexusIxianReplacement,null);
|
||||
assert.equal(seat(game,'a').spice,f.beforeSpice-2);
|
||||
assert.equal(seat(game,'a').hand.some(card => card.id === f.replacement!.id),true);
|
||||
assert.equal(seat(game,'r').hand.some(card => card.id === bought),claim);
|
||||
assert.equal(seat(game,'r').spice,sellerSpice+2, 'The original seller receipt settles once');
|
||||
assert.equal(game.nexusIxianReplacementHistory!.length,1);
|
||||
assert.equal(game.nexusIxianReplacementHistory![0].replacement!.id,f.replacement.id);
|
||||
assert.deepEqual(inventory(game),f.original);
|
||||
});
|
||||
|
||||
for (const claim of [false,true]) void test(`Basic Ixian Nexus replacement recycles an empty deck after Semuta ${claim ? 'claim' : 'pass'}`, () => {
|
||||
const f = fixture(true), before = structuredClone(f.game), bought = f.purchased.id;
|
||||
assert.equal(before.deck.length,0); assert.ok(before.discard.length > 0);
|
||||
const pending = act(f.game,'a',{type:'nexusIxianReplacementUse',event:f.game.pendingNexusIxianReplacement!.event});
|
||||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage,'offer');
|
||||
assert.equal(pending.deck.length,0, 'Recycling and replacement wait for Semuta');
|
||||
assert.equal(pending.discard.filter(card => card.id === bought).length,1);
|
||||
assert.equal(pending.nexusCards!.cards!.hands.a,null);
|
||||
const event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
let game = pending;
|
||||
if (claim) game = act(game,'r',{type:'semutaCommit',event});
|
||||
else for (const player of game.players) game = act(game,player.id,{type:'semutaPass',event});
|
||||
assert.equal(game.pendingTreacheryDiscard,null);
|
||||
assert.equal(game.pendingNexusIxianReplacement,null);
|
||||
assert.equal(game.nexusIxianReplacementHistory!.length,1);
|
||||
const replacement = game.nexusIxianReplacementHistory![0].replacement; assert.ok(replacement);
|
||||
assert.equal(seat(game,'a').hand.filter(card => card.id === replacement.id).length,1);
|
||||
assert.equal(seat(game,'a').hand.length,seat(before,'a').hand.length);
|
||||
assert.equal(seat(game,'a').spice,f.beforeSpice-2);
|
||||
assert.equal(seat(game,'r').hand.some(card => card.id === bought),claim);
|
||||
assert.equal(game.discard.length,0, 'The real discard pile was shuffled into the exhausted deck');
|
||||
assert.equal(game.nexusCards!.cards!.discard.filter(face => face === 'ixians').length,1);
|
||||
assert.deepEqual(inventory(game),f.original);
|
||||
});
|
||||
Reference in new issue
Block a user