Enable Basic Nexus Kull and native reactions
Verify and publish checkpoint container / container (push) Successful in 11m12s

This commit is contained in:
Quintine committed 2026-10-30 18:26:05 +11:00
1 parent 5b66628399
commit 95ebd7f976
15 files changed
+388 -21

No files matched your search

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