Connect Semuta with Nexus Kull in Basic games
Verify and publish checkpoint container / container (push) Successful in 10m47s

This commit is contained in:
Quintine committed 2026-10-30 18:40:01 +11:00
1 parent d4cd03afff
commit e697d7f558
13 files changed
+558 -24

No files matched your search

+1 -1
View File
@@ -248,7 +248,7 @@ 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 classic or Ixian/Tleilaxu factions, `choam,ix`
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
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`
+10
View File
@@ -17,6 +17,16 @@ open mode gates.
## Current checkpoint and work
**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
fuel, the original Karama and the one Nexus spend; distinct counters remain
public. Opting out of Kull keeps Semuta. Twelve related files pass 97 cases,
including public Ixian Bidding and shipment journeys with staged conserved
cards, positions and earned Nexus identities. Other modules, Advanced, wider
nested sources, complete games and deployed acceptance remain open.
[Scope](NEXUS_CARD_RULES.md#basic-richese-semuta-and-nexus-kull--30-october-2026).
**Basic Ixian Nexus Kull and Sandtrout — 30 October. Prototyped / Partial.**
Public Basic CHOAM/Ixian/Tleilaxu Nexus games now retain Kull and the original
Ixian Bidding/Nexus continuation. A waiting Sandtrout no longer suppresses an
+18
View File
@@ -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 Richese, Semuta and Nexus Kull — 30 October 2026
**Prototyped / Partial.** Fresh Basic Nexus games with CHOAM, Richese and
selected Ix and CHOAM decks can use both Kull and the existing Semuta
protocol. A successful printed Kull offers its public discard; a successful
Nexus-fueled Kull offers its actual private fuel after spending the Nexus and
committing its original effect. A distinct counter is a separate public
discard. Claim or pass resumes the original Karama and its already-committed
effects once. The saved private fuel is bound to its completed Cunning receipt.
The existing provisional reaction ordering remains an online interpretation.
The host can turn Kull off without losing Semuta in this selected-deck Nexus
profile. Public-start tests use real setup, earned Nexus and actual decisions;
unopened card order, physical custody, board positions and the drawn Nexus
identity are staged. Other optional modules, Advanced, other nested parents,
full games and deployed acceptance remain open. Existing games are not
reinitialized.
## Basic Ixian Nexus Kull and Sandtrout — 30 October 2026
**Prototyped / Partial.** Fresh Basic Nexus games with CHOAM, Ixians and/or
+9
View File
@@ -1,5 +1,14 @@
# Rule decision index
## Basic Richese, Semuta and Nexus Kull — 30 October 2026
The existing provisional Kull and Semuta ordering composes with Nexus Cunning:
the physical fuel is privately offered after the committed successful effect,
and a distinct counter is a separate public discard. Claim or pass preserves
the original Karama continuation and the single Nexus spend; no new publisher
timing ruling is implied.
[Scope](NEXUS_CARD_RULES.md#basic-richese-semuta-and-nexus-kull--30-october-2026).
## Basic Ixian Nexus Kull and Sandtrout — 30 October 2026
The existing Basic Kull and Ixian Nexus responses compose through the native
+53 -15
View File
@@ -7131,7 +7131,8 @@ function kullSemutaParentSupported(g: Game, frame?: NonNullable<Game['pendingKul
const battle = g.battle;
const use = frame?.intent.kind === 'ordinary' ? frame.intent.use : undefined;
const nativeResponse = !!frame && !g.advanced &&
(bareBasicSemutaProfile(g) || g.semutaRecruitsPreview === true && basicIndependentRecruitsSemutaProfile(g)) &&
(bareBasicSemutaProfile(g) || basicNexusKullSemutaProfile(g) ||
g.semutaRecruitsPreview === true && basicIndependentRecruitsSemutaProfile(g)) &&
use?.kind === 'cancel' && JSON.stringify(use.response) === JSON.stringify(frame.resume.response) &&
!frame.resume.decision && !frame.resume.pendingKarama && !frame.resume.phaseOpening;
let revivalSupported = false;
@@ -7200,10 +7201,29 @@ function kullSemutaDiscardSignature(g: Game, c: KullSemutaDiscardContinuation):
...(c.frame.auctionReceipt ? {auctionReceipt:c.frame.auctionReceipt, sale:g.currentAuctionSale,
round:g.richeseBidding, cache:g.richeseCache?.map(card => card.id), funding:g.richeseFunding, aid:g.aid} : {}),
restrictions: g.kullRestrictions, discard: g.discard.map(card => card.id),
...(c.frame.selection?.source === 'nexus' ? {nexusChoamHistory:g.nexusChoamHistory, nexusChoamLast:g.nexusChoamLast} : {}),
...(c.battleBinding ? {battle:g.battle} : {}),
players: g.players.map(player => ({id:player.id, hand:player.hand.map(card => card.id),
spice:player.spice, reserves:player.reserves, forces:player.forces, tanks:player.tanks})) });
}
/** A Nexus-fueled success is bound to its original physical Cunning receipt. */
function kullSemutaNexusReceiptMatches(g: Game, c: KullSemutaDiscardContinuation): boolean {
const selection = c.frame.selection;
if (c.kind !== 'kullSuccessDiscard' || selection?.source !== 'nexus' || !selection.nexusEvent) return false;
const record = g.nexusChoamHistory?.find(row => row.receipt.event === selection.nexusEvent);
if (!record || record.stage !== 'complete' || record.signature !== nexusChoamSignature(record) ||
record.receipt.owner !== c.player || record.receipt.card !== c.card || record.receipt.effect !== 'kull' ||
record.receipt.turn !== g.turn || record.receipt.phase !== g.phase ||
!g.nexusCards?.cards?.discard.includes('choam')) return false;
try {
nexusRule(() => validateNexusChoam(g, record.receipt));
const declared = JSON.parse(record.frame) as {owner?:string;card?:string;effect?:string;nexusEvent?:string};
const parent = JSON.parse(record.parent) as {kull?: {event?:string;signature?:string;selection?:unknown}};
return declared.owner === c.player && declared.card === c.card && declared.effect === 'kull' &&
declared.nexusEvent === selection.nexusEvent && parent.kull?.event === c.frame.event &&
parent.kull.signature === c.frame.signature && JSON.stringify(parent.kull.selection) === JSON.stringify(selection);
} catch { return false; }
}
function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinuation) {
const battle = c.kind === 'karamaBattleDiscard';
const worthless = c.kind === 'karamaChoamWorthlessDiscard';
@@ -8072,6 +8092,10 @@ function semutaNexusProfile(g: Game): boolean {
mixedE1E2NexusLeaderSkillsProfile(g)
: pairedNexusModulesProfile(g) || singleE1E2NexusProfile(g) || mixedE1E2NexusProfile(g)));
}
function basicNexusKullSemutaProfile(g: Game): boolean {
return g.advanced === false && g.semutaPreview === true && g.kullPreview === true &&
g.nexusKullPreview === true && nexusCoreProfile(g);
}
/** Original classic occupation or a focused Advanced Tleilax SEND profile.
* Other optional envelopes remain separate; no recorded game is redealt. */
function semutaHomeworldRoster(g: Game): boolean {
@@ -8256,11 +8280,12 @@ function treacheryDiscardIntegrity(g: Game) {
frame.event === JSON.stringify(['kull', g.turn, g.phase, g.kullSequence, frame.player, frame.owner]) &&
frame.signature === kullSignature(g, frame) &&
getPlayer(g, frame.owner).hand.some(card => card.id === frame.card) &&
batch.entries.length === 1 && entry.publicFace && entry.discardedBy === c.player &&
batch.entries.length === 1 && entry.publicFace === !(c.kind === 'kullSuccessDiscard' && frame.selection?.source === 'nexus') && entry.discardedBy === c.player &&
entry.card.id === c.card && canonicalKullCard(entry.card) &&
(c.kind === 'kullSuccessDiscard'
? c.player === frame.player && frame.selection?.source === 'printed' &&
frame.selection.card === c.card && !c.declaration && !c.use &&
? c.player === frame.player && (frame.selection?.source === 'printed' ||
basicNexusKullSemutaProfile(g) && kullSemutaNexusReceiptMatches(g, c)) &&
frame.selection?.card === c.card && !c.declaration && !c.use &&
!!g.kullRestrictions?.some(row => row.player === frame.owner && row.turn === g.turn && row.phase === g.phase)
: c.card !== frame.card && entry.card.effect === 'karama' && !!c.declaration &&
c.declaration.effect === 'kull' && c.declaration.owner === frame.player &&
@@ -9739,12 +9764,13 @@ function semutaOfferSupported(
!continuation.resume.response && !continuation.resume.decision &&
!continuation.resume.pendingKarama && !continuation.resume.phaseOpening;
if (continuation.kind === 'kullSuccessDiscard' || continuation.kind === 'kullCounterDiscard')
return (g.semutaPreview === true && bareBasicSemutaProfile(g) ||
return (g.semutaPreview === true && (bareBasicSemutaProfile(g) || basicNexusKullSemutaProfile(g)) ||
g.semutaRecruitsPreview === true && basicIndependentRecruitsSemutaProfile(g) &&
(!g.pendingTreacheryDiscard || !!g.pendingTreacheryDiscard.reaction)) && g.advanced === false &&
!!byFaction(g, 'richese') && kullSemutaParentSupported(kullSource(g, continuation.frame), continuation.frame) &&
(!!g.battle === !!continuation.battleBinding) && entries.length === 1 &&
entries[0].publicFace && entries[0].discardedBy === continuation.player &&
entries[0].publicFace === !(continuation.kind === 'kullSuccessDiscard' &&
continuation.frame.selection?.source === 'nexus') && entries[0].discardedBy === continuation.player &&
entries[0].card.id === continuation.card;
// These completed public costs retain the original ordinary lot through the
// existing signed continuation. Semuta itself has no during-bid exception;
@@ -11196,12 +11222,14 @@ 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-deck Nexus starts retain the existing native Semuta path. */
/** Fresh Basic CHOAM/optional Ix 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.length === 1 && g.expansions[0] === 'choam' && !!byFaction(g, 'richese') &&
g.players.every(p => p.faction === 'richese' || p.faction === 'choam' || CLASSIC_FACTIONS[p.faction] === true) &&
g.expansions.includes('choam') && g.expansions.length <= 2 &&
g.expansions.every(id => id === 'choam' || id === 'ix') && !!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.nexusCards && !g.homeworlds && !g.discoveryEnabled && !g.ecazTreachery && !g.leaderSkills &&
!g.techTokens && !g.strongholdCards && !g.twoPlayerControllers && !g.shortAllianceVictory &&
!g.coalitionPreview && !g.kullPreview && !g.nexusKullPreview;
@@ -12826,6 +12854,7 @@ function ixianNexusBetrayalIntegrity(g: Game): void {
const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume,
g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume,
g.summonedWorm?.resume, g.pendingKull?.resume,
continuation?.kind === 'kullCounterDiscard' ? continuation.frame.resume : null,
continuation && 'resume' in continuation ? continuation.resume : null];
const provider = byFaction(g, 'ixians')!.id;
const ownsWindow = (kind: 'ixAuction' | 'ixTechnology') => contexts.some(context => {
@@ -13726,12 +13755,13 @@ export function initializeNexusKullGameForAudit(state: Game): Game {
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 === 'ixians' || p.faction === 'tleilaxu')),
'Nexus Kull requires a fresh CHOAM lobby with classic or Basic Ixian/Tleilaxu factions, CHOAM and Ix decks, and only Nexus Cards.');
!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.');
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;
return g;
}
/** Prototype-only Discovery setup. Public starts stay gated while remaining effects are connected. */
@@ -20193,7 +20223,8 @@ function kullCostPromisesAllow(g: Game, p: Player, card: Card, fuelSource: 'prin
}
function kullCostCards(g: Game, p: Player, source: 'printed' | 'nexus' = 'printed'): Card[] {
const original = g.pendingKull ? kullSource(g, g.pendingKull) : g;
if (semutaCardProducerEnabled(g) && (!kullSemutaParentSupported(original, g.pendingKull ?? undefined) || source !== 'printed')) return [];
if (semutaCardProducerEnabled(g) && (!kullSemutaParentSupported(original, g.pendingKull ?? undefined) ||
source !== 'printed' && !basicNexusKullSemutaProfile(g))) return [];
if (source === 'nexus' && (!g.nexusKullPreview || p.faction !== 'choam' || p.ally ||
(g.pendingKull?.selection?.source !== 'nexus' && g.nexusCards?.cards?.hands[p.id] !== 'choam')))
return [];
@@ -20273,10 +20304,11 @@ function kullIntegrity(g: Game): void {
g.expansions.length === 2 && 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 === 'ixians' || p.faction === 'tleilaxu')) &&
!g.advanced && (p.faction === 'richese' || p.faction === 'ixians' || p.faction === 'tleilaxu')) &&
!g.homeworlds && !g.leaderSkills && !g.discoveryEnabled && !g.discoveries &&
!g.discoveryStash && !g.greatMaker &&
!g.techTokens && !g.strongholdCards && !g.ecazTreachery && !g.semutaPreview &&
!g.techTokens && !g.strongholdCards && !g.ecazTreachery &&
(!g.semutaPreview || !g.advanced && !!byFaction(g, 'richese')) &&
!g.richeseBetrayalPreview && !g.richeseBetrayal && !g.pendingRicheseBetrayal,
'The Nexus Kull profile has lost its supported native inventory and modules.');
if (g.kullRestrictions !== undefined) validateKullPhaseRestrictions(g, g.kullRestrictions);
@@ -38824,7 +38856,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
};
g.response = null; g.decision = null; g.pendingKarama = null; g.phaseOpening = null;
continuation.stateSignature = kullSemutaDiscardSignature(g, continuation);
stageTreacheryDiscard(g, 'choam:kull', [{card:used, discardedBy:choam.id, publicFace:true}], continuation);
stageTreacheryDiscard(g, 'choam:kull', [{card:used, discardedBy:choam.id, publicFace:pending.nexusEvent === undefined}], continuation);
}
return;
}
@@ -42008,10 +42040,16 @@ function nexusChoamIntegrity(g: Game) {
const karama = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null;
return [context?.response, karama?.use.kind === 'cancel' ? karama.use.response : null];
}).filter((response): response is ResponseWindow => response?.kind === 'choamWorthless');
const parkedCounter = continuation?.kind === 'kullCounterDiscard' && continuation.declaration?.nexusEvent
? {...g, pendingKull:continuation.frame, pendingChoamWorthless:continuation.declaration,
response:continuation.resume.response, decision:continuation.resume.decision,
pendingKarama:continuation.resume.pendingKarama, phaseOpening:continuation.resume.phaseOpening} : null;
const transactions = [
{ state: g, pending: g.pendingChoamWorthless, responses: responseList(controls) },
...(g.pendingKull ? [{ state: kullSource(g, g.pendingKull), pending: g.pendingKull.worthless,
responses: responseList([g.pendingKull.resume]) }] : []),
...(parkedCounter && continuation?.kind === 'kullCounterDiscard' ? [{ state: parkedCounter,
pending: continuation.declaration, responses: responseList([continuation.resume]) }] : []),
];
const declared = transactions.filter(transaction => transaction.pending?.nexusEvent);
const history = g.nexusChoamHistory;
+3 -3
View File
@@ -103,13 +103,13 @@ function basicNativeNexusCore(game: NexusCoreConfiguration): boolean {
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 ||
if (game.semutaPreview && (game.semutaPreview !== true ||
!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 ||
if (game.kullPreview !== true || game.nexusKullPreview !== true ||
game.expansions.length !== 2 || !game.expansions.includes('ix') || !game.expansions.includes('choam') ||
!players.some(player => player.faction === 'choam') ||
players.some(player => player.faction !== 'choam' && player.faction !== 'ixians' && player.faction !== 'tleilaxu' &&
players.some(player => player.faction !== 'choam' && player.faction !== 'richese' && player.faction !== 'ixians' && player.faction !== 'tleilaxu' &&
!FACTIONS.some(faction => faction.id === player.faction && faction.expansion === 'base'))) return false;
}
return true;
+2 -2
View File
@@ -61,12 +61,12 @@ export function kullDefaultProfile(game: KullDefaultProfile): KullDefaultEligibi
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) || ix.has(player.faction)) ||
!game.players.every(player => choam.has(player.faction) || classic.has(player.faction) || ix.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, Ixian/Tleilaxu or classic factions, the Ix and CHOAM decks, and no other optional modules.');
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 { eligible: true, reason: null };
}
if (options?.leaderSkills || projected?.leaderSkills ||
+2 -1
View File
@@ -5993,7 +5993,8 @@ 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 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. The separate local Advanced audit remains available. Other optional modules, Richese, Semuta and Richese Betrayal stay outside this profile; existing games are not converted.',
'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.',
'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.',
'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.',
+1 -1
View File
@@ -12,6 +12,7 @@ void test('Basic CHOAM and classic Nexus lobby receives the same public Kull def
assert.deepEqual(kullDefaultProfile(lobby), { eligible: true, reason: null });
assert.equal(kullDefaultEligible({ ...lobby, players: [{ faction: 'choam' }] }), true);
assert.equal(kullDefaultEligible({ ...lobby, players: [{ faction: 'choam' }, { faction: 'ixians' }, { faction: 'tleilaxu' }] }), true);
assert.equal(kullDefaultEligible({ ...lobby, players: [{ faction: 'choam' }, { faction: 'richese' }] }), 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.
@@ -23,7 +24,6 @@ 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: 'atreides' }, { faction: 'atreides' }] },
{ players: [{ faction: 'atreides' }, { faction: 'guild' }] },
{ lobbyOptions: { leaderSkills: true } }, { lobbyOptions: { ecazTreachery: true } },
+44
View File
@@ -0,0 +1,44 @@
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: 'richese' }, { faction: 'atreides' }],
nexusCards: { cards: null, phase: null },
};
void test('Basic Nexus Kull public quote admits Richese with either or both native Ix factions', () => {
for (const factions of [[], ['ixians'], ['tleilaxu'], ['ixians', 'tleilaxu']]) {
const selected = { ...lobby, players: [...lobby.players, ...factions.map(faction => ({ faction }))] };
assert.deepEqual(kullDefaultProfile(selected), { eligible: true, reason: null });
assert.equal(kullDefaultEligible({ ...selected, nexusCards: undefined, lobbyModules: { nexusCards: true } }), true);
}
// Explicit host opt-out is handled by the lobby caller, not eligibility.
const optedOut = { leaderSkills: false, kullOptOut: true };
assert.equal(kullDefaultEligible({ ...lobby, lobbyOptions: optedOut }), true);
});
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'] },
{ players: [{ faction: 'richese' }, { faction: 'atreides' }] },
{ players: [{ faction: 'choam' }, { faction: 'richese' }, { faction: 'richese' }] },
{ players: [{ faction: 'choam' }, { faction: 'richese' }, { faction: 'ecaz' }] },
{ lobbyOptions: { leaderSkills: true } }, { lobbyOptions: { ecazTreachery: true } },
{ lobbyModules: { nexusCards: true, discoveries: true } }, { homeworlds: {} },
{ techTokens: {} }, { strongholdCards: {} }, { discoveryEnabled: true },
];
for (const variant of invalid) {
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('Richese Nexus Kull quote reads no player hand or spice', () => {
const richese = { faction: 'richese', get hand() { throw Error('private hand read'); },
get spice() { throw Error('private spice read'); } };
assert.equal(kullDefaultEligible({ ...lobby, players: [{ faction: 'choam' }, richese] }), true);
});
+4 -1
View File
@@ -47,10 +47,13 @@ void test('fresh admission accepts exactly native classic CHOAM, physical CHOAM/
assert.deepEqual(initialized.spiceDeck.map(card => JSON.stringify(card)).sort(),
spiceDeck(true).map(card => JSON.stringify(card)).sort());
assert.equal(initialized.spiceDeck.filter(card => 'sandtrout' in card).length, 1);
const withRichese = lobby();
withRichese.players[1] = newPlayer('e', 'Richese', 'richese'); withRichese.players[1].ready = true;
const joint = initializeNexusKullGameForAudit(withRichese);
assert.equal(joint.semutaPreview, true);
for (const corrupt of [
(g: Game) => { g.expansions = ['choam']; },
(g: Game) => { g.nexusCards = undefined; },
(g: Game) => { g.players[1] = newPlayer('e', 'Richese', 'richese'); g.players[1].ready = true; },
(g: Game) => { g.semutaPreview = true; },
(g: Game) => { g.kullSequence = 1; },
(g: Game) => { g.turn = 2; },
+182
View File
@@ -0,0 +1,182 @@
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;
}
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 Ixian 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 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() {
let game = createGame('NXKULLIX', newPlayer('ix','Ixians','ixians'), false, ['ix','choam']);
for (const [id,faction] of [['t','tleilaxu'],['c','choam'],['r','richese'],['a','atreides'],['g','guild']] as const)
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);
assert.equal(game.nexusIxianBetrayalPreview,true);
game = advance(game,state => state.status === 'playing');
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));
orderSpice(game,['worm','land','land']);
game = advance(game,state=>state.nexus && !state.spiceWindow && !state.spiceResolution && clean(state));
game = act(game,'ix',{type:'alliance',target:'t'}); game = act(game,'t',{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,'g').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 = takeKullCard(game,SEMUTA_DRUG_ID); seat(game,'r').hand.push(semuta);
assert.deepEqual(stock(game),original);
game = act(game,'g',{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 claim of [false,true]) void test(
`Basic Richese/Ixian Nexus auction survives Nexus Kull and Semuta ${claim?'claim':'pass'}`, () => {
const source = 'nexus' as const;
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 === 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(', ')}`);
let game=act(f.game,'c',{type:'kullDecision',event:f.event,source,card:f.fuel});
game=passKullCounter(game);
assert.equal(game.pendingKull,null);
assert.equal(seat(game,'g').hand.some(card=>card.id===f.karama),true);
assert.equal(game.pendingTreacheryDiscard?.batch.entries[0].card.id,f.fuel);
assert.equal(game.pendingTreacheryDiscard?.batch.entries[0].publicFace,false);
game=semutaReaction(game,claim);
assert.equal(seat(game,'r').hand.some(card=>card.id===f.fuel),claim);
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 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);
});
+229
View File
@@ -0,0 +1,229 @@
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 { advanceSemutaNexusTrade, swapSemutaNexusTradeIdentity } from './fixture-semuta-nexus-trade';
import { beforeIxSpiceDraw, orderIxSpiceCards } from './fixture-ix-spice-cards';
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.phaseOpening && !game.response && !game.decision && !game.truthtrance && !game.pendingGuildBetrayal;
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 original physical card has one location');
return cards;
}
/** Fresh public Basic setup and a genuinely earned CHOAM Nexus. Unopened
* Spice order, the earned Nexus identity, original card custody and starting
* board counters are staged without creating physical components. */
function fixture() {
let game = createGame('NEXKULLSEMUTA', newPlayer('c', 'CHOAM', 'choam'), false, ['ix', 'choam']);
for (const [id, faction] of [['r','richese'], ['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 player of game.players) game = act(game, player.id, {type:'ready'});
game = act(game, 'c', {type:'start'});
assert.equal(game.semutaPreview, true); assert.equal(game.kullPreview, true); assert.equal(game.nexusKullPreview, true);
game = advanceSemutaNexusTrade(game, state => state.status === 'playing');
assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card => card === null));
const original = inventory(game);
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((sum, amount) => sum + amount, 0); player.forces = {};
if (player.elites) { player.elites.reserves += Object.values(player.elites.forces).reduce((sum, amount) => sum + amount, 0); player.elites.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.phase === 5 && state.active === 'g' && clean(state));
assert.equal(game.nexusCards!.cards!.hands.c, 'choam');
// Move only original cards; stage after prior CHOAM market actions finish.
for (const player of game.players) game.deck.push(...player.hand.splice(0));
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 originalKarama = takeKullCard(game, 'karama'); seat(game, 'g').hand.push(originalKarama);
const counter = takeKullCard(game, 'karama'); seat(game, 'h').hand.push(counter);
const semuta = takeKullCard(game, SEMUTA_DRUG_ID); seat(game, 'r').hand.push(semuta);
assert.deepEqual(inventory(game), original);
game = act(game, 'g', {type:'card', mode:'shipment', card:originalKarama.id, target:'g'});
assert.equal(game.pendingKull?.stage, 'offer');
return {game, original, printed:printed.id, fuel:fuel.id, karama:originalKarama.id, counter:counter.id, semuta:semuta.id, event:game.pendingKull!.event};
}
function finishCounter(state: Game): Game {
let game = state;
for (let n = 0; game.pendingKull && n < 20; n++) {
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);
}
assert.equal(game.pendingKull, null); return game;
}
function passFreshEvent(state: Game): Game {
let game = state;
const event = game.pendingTreacheryDiscard?.batch.event; assert.ok(event);
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 public Richese Nexus ${source} Kull success offers Semuta ${claim ? 'claim' : 'pass'}`, () => {
const f = fixture(), cost = source === 'printed' ? f.printed : f.fuel;
const controls = viewGame(f.game, 'c').kullReaction!;
assert.ok(controls.plays.some(row => row.source === source && row.card.id === cost));
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));
}
const startLog = f.game.log.length;
let game = act(f.game, 'c', {type:'kullDecision', event:f.event, source, card:cost});
game = finishCounter(game);
const pending = game.pendingTreacheryDiscard; assert.ok(pending);
assert.equal(pending.reaction?.stage, 'offer');
assert.deepEqual(pending.batch.entries.map(entry => ({id:entry.card.id, discardedBy:entry.discardedBy, publicFace:entry.publicFace})),
[{id:cost, discardedBy:'c', publicFace:source === 'printed'}]);
assert.equal(game.karamaShipping, null); assert.ok(seat(game, 'g').hand.some(card => card.id === f.karama));
assert.equal(game.nexusCards!.cards!.hands.c, source === 'printed' ? 'choam' : null);
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'choam').length, Number(source === 'nexus'));
if (source === 'nexus') {
assert.deepEqual(viewGame(game, 'r').semutaReaction!.candidates, []);
assert.deepEqual(viewGame(game, 'g').semutaReaction!.candidates, []);
assert.equal(game.log.slice(startLog).some(row => row.text.includes('Hunter-Seeker')), false);
assert.equal(game.nexusChoamHistory!.at(-1)!.stage, 'complete');
}
assert.equal(viewGame(game, 'r').semutaReaction!.canCommit, true);
const event = pending.batch.event;
if (claim) {
game = act(game, 'r', {type:'semutaCommit', event});
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
assert.deepEqual(viewGame(game, 'r').semutaReaction!.candidates.map(card => card.id), [cost]);
assert.deepEqual(viewGame(game, 'g').semutaReaction!.candidates, []);
game = act(game, 'r', {type:'semutaSelect', event, card:cost});
}
} 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.equal(game.pendingTreacheryDiscard, null); assert.equal(game.pendingKull, null);
assert.equal(game.karamaShipping, null); assert.ok(seat(game, 'g').hand.some(card => card.id === f.karama));
assert.ok(viewGame(game, 'g').karamaBlocked);
assert.equal(seat(game, 'r').hand.some(card => card.id === cost), claim);
assert.equal(game.discard.some(card => card.id === cost), !claim);
assert.equal(game.discard.some(card => card.id === f.semuta), claim);
assert.deepEqual(inventory(game), f.original);
});
for (const source of ['printed','nexus'] as const) for (const claim of [false,true]) void test(
`Basic public Richese Nexus ${source} Kull counter offers its distinct Karama to Semuta ${claim ? 'claim' : 'pass'}`, () => {
const f = fixture(), cost = source === 'printed' ? f.printed : f.fuel;
let game = act(f.game, 'c', {type:'kullDecision', event:f.event, source, card:cost});
assert.ok(viewGame(game, 'h').responseControls?.cancelCards.includes(f.counter));
game = act(game, 'h', {type:'card', mode:'cancel', card:f.counter});
const pending = game.pendingTreacheryDiscard; assert.ok(pending);
assert.equal(pending.reaction?.stage, 'offer');
assert.deepEqual(pending.batch.entries.map(entry => ({id:entry.card.id, discardedBy:entry.discardedBy, publicFace:entry.publicFace})),
[{id:f.counter, discardedBy:'h', publicFace:true}]);
assert.ok(seat(game, 'c').hand.some(card => card.id === cost));
assert.ok(seat(game, 'g').hand.some(card => card.id === f.karama));
assert.equal(game.nexusCards!.cards!.hands.c, source === 'printed' ? 'choam' : null);
const event = pending.batch.event;
if (claim) {
game = act(game, 'r', {type:'semutaCommit', event});
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
game = act(game, 'r', {type:'semutaSelect', event, card:f.counter});
} 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});
}
// The original allowed Karama is a distinct fresh public discard after the
// counter's own batch; acknowledge it before checking shipment continuation.
assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event);
assert.equal(game.pendingTreacheryDiscard?.batch.entries[0].card.id, f.karama);
game = passFreshEvent(game);
assert.equal(game.pendingTreacheryDiscard, null); assert.equal(game.pendingKull, null);
assert.equal(game.karamaShipping?.owner, 'g');
assert.ok(seat(game, 'c').hand.some(card => card.id === cost));
assert.equal(seat(game, 'r').hand.some(card => card.id === f.counter), claim);
assert.equal(game.discard.some(card => card.id === f.counter), !claim);
assert.equal(game.discard.some(card => card.id === f.semuta), claim);
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'choam').length, Number(source === 'nexus'));
assert.deepEqual(inventory(game), f.original);
});
void test('Basic public Richese Nexus Kull may decline without spending its cards', () => {
const f = fixture();
let game = act(f.game, 'c', {type:'kullDecision', event:f.event, decline:true});
assert.equal(game.pendingTreacheryDiscard?.batch.entries[0].card.id, f.karama);
game = passFreshEvent(game);
assert.equal(game.pendingKull, null); assert.equal(game.karamaShipping?.owner, 'g');
assert.equal(game.nexusCards!.cards!.hands.c, 'choam');
assert.ok(seat(game, 'c').hand.some(card => card.id === f.printed));
assert.ok(seat(game, 'c').hand.some(card => card.id === f.fuel));
assert.ok(seat(game, 'r').hand.some(card => card.id === f.semuta));
assert.deepEqual(inventory(game), f.original);
});
void test('Basic public Richese Nexus keeps Semuta when the host opts out of Kull', () => {
let game = createGame('NEXKULLOPTOUT', newPlayer('c', 'CHOAM', 'choam'), false, ['ix', 'choam']);
for (const [id, faction] of [['r','richese'], ['a','atreides'], ['g','guild'], ['h','harkonnen']] as const)
joinGame(game, newPlayer(id, faction, faction));
game = act(game, 'c', {type:'moduleOption', module:'nexusCards', enabled:true});
game = act(game, 'c', {type:'moduleOption', module:'kullPreview', enabled:false});
for (const player of game.players) game = act(game, player.id, {type:'ready'});
game = act(game, 'c', {type:'start'});
assert.equal(game.semutaPreview, true);
assert.notEqual(game.kullPreview, true); assert.notEqual(game.nexusKullPreview, 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, amount) => sum + amount, 0); player.forces = {};
if (player.elites) { player.elites.reserves += Object.values(player.elites.forces).reduce((sum, amount) => sum + amount, 0); player.elites.forces = {}; }
}
game = advanceSemutaNexusTrade(game, beforeIxSpiceDraw); orderIxSpiceCards(game, ['land','land']);
game = advanceSemutaNexusTrade(game, state => state.phase === 5 && state.active === 'g' && clean(state));
// Conserved original hand/deck/cache cards; Hajr and Semuta are used by
// their real owners after public setup and the actual movement opening.
for (const player of game.players) game.deck.push(...player.hand.splice(0));
const hajr = takeKullCard(game, 'hajr'); seat(game, 'g').hand.push(hajr);
const semuta = takeKullCard(game, SEMUTA_DRUG_ID); seat(game, 'r').hand.push(semuta);
assert.deepEqual(inventory(game), original);
const pending = act(game, 'g', {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]) {
let resumed = structuredClone(pending);
const event = pending.pendingTreacheryDiscard!.batch.event;
if (claim) {
resumed = act(resumed, 'r', {type:'semutaCommit', event});
if (resumed.pendingTreacheryDiscard?.reaction?.stage === 'select')
resumed = act(resumed, 'r', {type:'semutaSelect', event, card:hajr.id});
} else resumed = passFreshEvent(resumed);
assert.equal(resumed.pendingTreacheryDiscard, null);
assert.ok(resumed.hajr.includes('g'));
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(inventory(resumed), original);
resumed = act(resumed, 'g', {type:'ship', territory:'polar_sink', sector:0, amount:1});
assert.equal((seat(resumed, 'g').forces.polar_sink ?? 0) + (seat(resumed, 'g').forces['polar_sink:0'] ?? 0), 1);
}
});