Compose selected E1/E2 independent cards with Nexus rules
Retain selected decks and original optional modules through normal setup, distinguish native Ixian Betrayal from absent-faction replacement, route explicit CHOAM fuel before printed effects, and preserve native No-Field invoices and pair continuation. Connect canonical native cancellation stock and existing human/minimal AI consumers. Validation: 194 affected tests across 19 files, types/lint/build, bounded native invoice review and seven finite original-action/rendered-control programmes. Runtime ec5ad6c18996d8d958ec0d01132ef3e3ea6d23d36188902eca9a63d4db2efbf8; documentation-only proof overlay a5892004937434429916496497c07a3838a324b4a5d64d4a0eb804ab5212d5bd. Other envelopes, full combinations, comprehensive assurance and deployed acceptance remain open. No saved-room/provider changes.
This commit is contained in:
1 parent
999edb67e6
commit
8422cf1f2d
20 files changed
+1409
-93
No files matched your search
+12
-10
@@ -14,7 +14,7 @@ import { AdvancedPreviewNotice, RulesetControls } from './ruleset-controls';
|
||||
import { EcazOccupyLeadChoice, EcazOccupyBattleSummary, EcazOccupyDialExplanation } from './ecaz-occupy';
|
||||
import { ecazOccupyOwnProfile, ecazOccupyPlanControl } from '@/game/ecaz-occupy-options';
|
||||
import { nexusGuildCunningAction, nexusGuildCunningActive, nexusGuildMovementAvailable, nexusGuildShipmentAvailable, nexusGuildSkipShipmentAction } from '@/game/nexus-guild-cunning-options';
|
||||
import { classicNexusCoreProfile } from '@/game/faction-module-profile';
|
||||
import { nexusCoreProfile } from '@/game/faction-module-profile';
|
||||
import {
|
||||
matchesShipment,
|
||||
liveShipmentPromises,
|
||||
@@ -262,8 +262,8 @@ export function GameTable({
|
||||
const movementAvailable = nexusGuildMovementAvailable(g);
|
||||
const guildCunning = nexusGuildCunningActive(g);
|
||||
const guildCunningAction = g.nexusGuildCunning?.offer ? nexusGuildCunningAction(g, g.nexusGuildCunning.offer.event) : null;
|
||||
const classicNexusPreview = classicNexusCoreProfile(g) &&
|
||||
(g.guildBetrayalPreview || g.nexusIxianReplacementPreview || g.nexusHarkonnenBetrayalPreview);
|
||||
const coreNexusPreview = nexusCoreProfile(g) &&
|
||||
(g.guildBetrayalPreview || g.nexusIxianReplacementPreview || g.nexusHarkonnenBetrayalPreview || g.nexusIxianBetrayalPreview);
|
||||
const combatName = (id: string) =>
|
||||
g.combatLocations?.find((place) => place.id === id)?.name ??
|
||||
gameTerritories(g).find((place) => place.id === id)?.name ??
|
||||
@@ -1290,10 +1290,12 @@ export function GameTable({
|
||||
<a href="/rules?topic=nexus-richese-betrayal#nexus-richese-betrayal">Preview rules and limits</a>
|
||||
</p>
|
||||
)}
|
||||
{classicNexusPreview && (
|
||||
{coreNexusPreview && (
|
||||
<p className="notice" role="status">
|
||||
Classic Nexus reactions preview · Basic/Advanced classic factions, original base33
|
||||
or selected independent36 Treachery, optionally all14 Leader Skills and Homeworlds.
|
||||
Nexus core reactions preview · Basic/Advanced classic factions with base33 or
|
||||
independent36, or selected Ixian/CHOAM decks with the independent three-card
|
||||
variant, permitting registered classic and selected native seats. Optional all14
|
||||
Leader Skills and Homeworlds compose.
|
||||
Own-World Reinforcements fees provisionally spend ordinary non-role counters first,
|
||||
reducing temporary Sardaukar roles only when converted counters pay.
|
||||
Original Tech (3+ seats) and Advanced Strongholds (2+ seats) may compose. Native reactions
|
||||
@@ -1303,7 +1305,7 @@ export function GameTable({
|
||||
<a href="/rules?topic=nexus-cards#nexus-cards">Preview rules and limits</a>
|
||||
</p>
|
||||
)}
|
||||
{g.guildBetrayalPreview && !classicNexusPreview && (
|
||||
{g.guildBetrayalPreview && !coreNexusPreview && (
|
||||
<p className="notice" role="status">
|
||||
Guild Nexus Betrayal development preview · whole funded shipment
|
||||
payment replacement, including your own payment and occupied Junction.
|
||||
@@ -1312,7 +1314,7 @@ export function GameTable({
|
||||
<a href="/rules?topic=nexus-guild-betrayal#nexus-guild-betrayal">Preview rules and limits</a>
|
||||
</p>
|
||||
)}
|
||||
{g.nexusIxianReplacementPreview && !classicNexusPreview && (
|
||||
{g.nexusIxianReplacementPreview && !coreNexusPreview && (
|
||||
<p className="notice" role="status">
|
||||
Ixian Nexus purchased-card replacement development preview · original supported
|
||||
standalone Tleilaxu or Richese entries. Normal paid/Karama and supported native
|
||||
@@ -1321,7 +1323,7 @@ export function GameTable({
|
||||
<a href="/rules?topic=nexus-ixian-replacement#nexus-ixian-replacement">Preview rules and limits</a>
|
||||
</p>
|
||||
)}
|
||||
{g.nexusIxianBetrayalPreview && (
|
||||
{g.nexusIxianBetrayalPreview && !coreNexusPreview && (
|
||||
<p className="notice" role="status">
|
||||
Ixian Nexus Betrayal development preview · native Ixian Bidding extra-card
|
||||
inspection/draw and Advanced Technology exchange prevention. Native Ixians,
|
||||
@@ -1330,7 +1332,7 @@ export function GameTable({
|
||||
<a href="/rules?topic=nexus-ixian-betrayal#nexus-ixian-betrayal">Preview rules and limits</a>
|
||||
</p>
|
||||
)}
|
||||
{g.nexusHarkonnenBetrayalPreview && !classicNexusPreview && (
|
||||
{g.nexusHarkonnenBetrayalPreview && !coreNexusPreview && (
|
||||
<p className="notice" role="status">
|
||||
Harkonnen Nexus Betrayal development preview · cancel an actually declared native Harkonnen
|
||||
traitor call, return that physical card to the Traitor Deck, and draw one private replacement
|
||||
|
||||
@@ -16,6 +16,32 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Selected-deck independent Nexus — 18 October. Prototyped / Partial.**
|
||||
Original selected Ixian/CHOAM/combined decks now compose independent three-card
|
||||
selection and Nexus12, optional all14/World/Tech3+/Advanced Strongholds2+.
|
||||
Normal host entry retains selected stock38/50 and native Richese cache10 without
|
||||
requiring a native from each selected family. Fresh Ixian reactions distinguish
|
||||
native Betrayal from absent-faction Secret Ally replacement. Explicit CHOAM
|
||||
conversion cannot execute its fuel card’s printed effect. Real No-Field pairs
|
||||
retain their signed continuation through Guild payment acknowledgements.
|
||||
|
||||
Frozen19-file **194/194**, types/lint/build pass. Seven finite original-action
|
||||
programmes pass: normal host/training19, mixed-fee Cunning/Harass181,
|
||||
Cyborg substitution/matching Face Dance188, Richese pair/reveal/fee160,
|
||||
actual bribe/paid-shipment two-reserve refusal199, CHOAM fuel140 and native
|
||||
cache/replacement129 actions. Rendered entry, selected-plan and refusal controls
|
||||
retain their real choices; framework aliases/CSS stubs mean no visual/browser
|
||||
acceptance is claimed. Four minimal legal policies pass the affected batch.
|
||||
|
||||
Iteration fixed real role/dispatch/continuation defects and corrected fixture
|
||||
assumptions about native Tech ownership, actual wallet limits, canonical0/3/5
|
||||
markers, legal Storm dials and unopened Face Dancer lotteries. No printed
|
||||
numeric rule was changed for a fixture. Bounded native invoice review also
|
||||
fixed preflight priority and the original allied Guild bank tariff. No saved
|
||||
room/database, reset, restart or deployment. Wider envelopes, comprehensive
|
||||
assurance and deployed acceptance remain open.
|
||||
[Source, controls and boundaries](NEXUS_CARD_RULES.md#selected-e1e2-independent-nexus--18-october-2026).
|
||||
|
||||
**Readable room confirmations — 18 October.** The user-requested create/join
|
||||
confirmation stays above the table until local Continue, independently of
|
||||
polling and game actions. Shared toasts default to no timeout and a visible
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 18 October 2026 — Selected E1/E2 independent Nexus
|
||||
|
||||
Selected Ixian/CHOAM/combined38/50 composes independent cards and Nexus12 with
|
||||
original optional modules through normal host setup. Native Ix gets Betrayal;
|
||||
only absence gets purchased-card Secret Ally. CHOAM conversion bypasses printed
|
||||
fuel effects. Native typed fees/Harass/substitution/Face Dance and Richese
|
||||
pair/reveal/cache/Guild continuations use original sources and controls.
|
||||
|
||||
Three disjoint helper/fixture lanes were integrated by one owner; bounded native
|
||||
invoice review found preflight and tariff issues, now repaired. Frozen19-file
|
||||
194/194, types/lint/build and seven finite original-action/rendered-control
|
||||
programmes pass. Fixture corrections preserve actual marker values, native
|
||||
ownership, affordability and unopened lotteries; no printed arithmetic changed.
|
||||
[Canonical proof and remaining boundaries](NEXUS_CARD_RULES.md#selected-e1e2-independent-nexus--18-october-2026).
|
||||
No saved-room/reset/restart/deployment; full modes and final assurance remain open.
|
||||
|
||||
## 18 October 2026 — Persistent room entry confirmations
|
||||
|
||||
Successful create/join now shows the room code and concise invitation/seat
|
||||
|
||||
@@ -2,6 +2,69 @@
|
||||
|
||||
Original source audit, 10 September 2026, with dated bounded runtime follow-ups below. This document establishes the printed component inventory, source protocol and implementation boundaries; none lifts expansion release gates. The ordinary worm-triggered alliance Nexus already in the engine is distinct from this optional card module.
|
||||
|
||||
## Selected E1/E2 independent Nexus — 18 October 2026
|
||||
|
||||
**Prototyped / Partial.** Fresh selected Ixian, CHOAM or combined decks now
|
||||
compose the independent three-card variant with Nexus12, optionally all14 Skills,
|
||||
Homeworlds, Tech3+ and Advanced Strongholds2+. Registered classic and selected
|
||||
native seats are allowed, without requiring a native from every selected deck.
|
||||
Normal lobby selections retain ordinary38 (CHOAM) or50 (Ix/combined); a seated
|
||||
Richese keeps the separate original ten-card cache. Existing games are not
|
||||
reflagged. This extends the classic-only boundary recorded below.
|
||||
|
||||
The composition follows the existing E3 independent-module contract (original
|
||||
pp4/9/11) and each native effect's linked source; no new printed amount is added.
|
||||
Fresh reaction state follows actual faction presence: native Ixians get Betrayal,
|
||||
and their absence permits Secret Ally purchased-card replacement. A native Ixian
|
||||
holding its own card cannot use that absent-faction effect. Native cancellation
|
||||
validates the selected canonical deck and any actual Richese cache, retaining
|
||||
the existing receipt grammar and provisional acknowledgement order.
|
||||
|
||||
Actual card paths include Suboid Cunning with mixed ordinary/Cyborg reserve fees,
|
||||
Harass returns before residual native Cyborg substitution and Face Dance; CHOAM
|
||||
Cunning treats an independent card as ordinary fuel without executing its printed
|
||||
effect. Its explicit conversion mode routes before printed-card handlers, so
|
||||
Recruits cannot intercept the fuel action. Richese's original Cunning pair retains its signed shipment through the
|
||||
Guild invoice, then materializes concealed forces before quoting Reinforcements.
|
||||
The original fee/support/face traits and no-extra-prompt rule for a unique Harass
|
||||
allocation remain. Local World fee priority keeps the earlier explicit
|
||||
provisional interpretation, rather than creating a new timing ruling.
|
||||
|
||||
Guild invoices validate original own/allied No-Field sources and their approved
|
||||
payment legs; supplier-funded loans use their actual approval, not a fabricated
|
||||
ally pledge. The acceptance preflight tests native arrival without opening an
|
||||
invoice inside its still-owned decision. Live payment reactions follow native
|
||||
counters. The original allied Guild bank tariff remains authoritative.
|
||||
|
||||
Implementation and usable controls share `nexusCoreProfile` /
|
||||
`independentNexusProfile`, original engine continuations, existing source/card
|
||||
controls and legal policies. Evidence paths are
|
||||
`tests/selected-independent-nexus-entry.test.ts`,
|
||||
`tests/selected-independent-nexus-battles.test.ts`,
|
||||
`tests/selected-independent-nexus-cards.test.ts` and
|
||||
`tests/selected-independent-ixian-betrayal.test.ts`, with their original-action
|
||||
fixtures. Other E3/Discovery/Semuta envelopes and remaining Nexus timing variants
|
||||
are separate; this is not complete Nexus, combination or release acceptance.
|
||||
Owner frozen19-file **194/194**, types/lint/build pass. Seven finite original-action
|
||||
programmes complete normal selected entry/training19, Suboid mixed-fee/Harass181,
|
||||
Cyborg substitution plus genuine matching Face Dance188, Richese pair/reveal/fee160,
|
||||
real bribe/paid-shipment two-reserve refusal199, CHOAM fuel140 and original cache
|
||||
replacement129 actions. Rendered entry, plan and refusal controls retain actual
|
||||
choices; SSR aliases framework imports and stubs CSS, so this is not visual or
|
||||
browser acceptance. Four minimal legal policies pass the focused batch.
|
||||
|
||||
Iteration exposed the native/absent Ixian role mixup, printed Recruits intercepting
|
||||
CHOAM conversion and an invoice-owned Richese continuation. Those runtime defects
|
||||
are repaired. Fixture repairs retain printed native Tech ownership, actual wallet
|
||||
limits, canonical markers0/3/5, legal Storm dials and original unopened Face Dancer
|
||||
lotteries. The poor source is now a real nine-spice bribe and paid ten-force
|
||||
shipment before turn-three Cunning, not an invented marker. No game numerical
|
||||
rule was changed to satisfy fixtures. Bounded source review fixed native allied
|
||||
invoice preflight and its original bank-only tariff. Earlier diagnostics are
|
||||
retained outside Git. No saved room/database, reset, restart or deployment;
|
||||
comprehensive combinations, custody/privacy/recovery and deployed acceptance
|
||||
remain open.
|
||||
|
||||
## Classic independent World Nexus — 18 October 2026
|
||||
|
||||
**Prototyped / Partial.** Fresh classic2–6 Homeworlds + Nexus12 now also
|
||||
|
||||
+99
-40
@@ -1,5 +1,5 @@
|
||||
import { classicNexusModulesProfile, mixedE1E2NexusProfile, mixedE3NexusProfile, pairedE3NexusProfile, pairedNexusModulesProfile, singleE1E2NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
|
||||
import { classicIndependentNexusProfile, classicNexusCoreProfile, nativeFactionTechProfile } from './faction-module-profile';
|
||||
import { independentNexusProfile, nexusCoreProfile, nativeFactionTechProfile } from './faction-module-profile';
|
||||
import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, homeworldDiscoveryProfile, discoveryModeSupported, discoveryModuleProfile, mixedE1E2DiscoveryNexusProfile, mixedE3DiscoveryNexusProfile, pairedDiscoveryNexusProfile, pairedE3DiscoveryNexusProfile, singleE1E2DiscoveryNexusProfile, standaloneE3DiscoveryNexusProfile } from './discovery-module-profile';
|
||||
import { advancedMoritaniLeaderSkillsProfile, selectedDeckLeaderSkillsProfile, homeworldLeaderSkillsProfile, independentTreacheryLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, guildBetrayalNexusLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeEcazLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedE3LeaderSkillsProfile, pairedE3NexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, singleE1E2NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile';
|
||||
import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options';
|
||||
@@ -9674,7 +9674,7 @@ export function initializeHarkonnenNexusBetrayalGameForAudit(state: Game): Game
|
||||
return initializeNexusGameForAudit(state);
|
||||
}
|
||||
function harkonnenNexusBetrayalModeSupported(g: Game): boolean {
|
||||
if (classicNexusCoreProfile(g)) return !!byFaction(g, 'harkonnen');
|
||||
if (nexusCoreProfile(g)) return !!byFaction(g, 'harkonnen');
|
||||
return typeof g.advanced === 'boolean' && !!g.nexusCards && g.expansions.length === 0 &&
|
||||
g.players.length >= 2 && g.players.length <= 6 && !!byFaction(g,'harkonnen') &&
|
||||
new Set(g.players.map(p => p.faction)).size === g.players.length &&
|
||||
@@ -9883,6 +9883,7 @@ export function initializeIxianNexusBetrayalGameForAudit(state: Game): Game {
|
||||
return g;
|
||||
}
|
||||
function ixianNexusBetrayalModeSupported(g: Game): boolean {
|
||||
if (nexusCoreProfile(g)) return !!byFaction(g, 'ixians');
|
||||
return typeof g.advanced === 'boolean' && !!g.nexusCards &&
|
||||
g.expansions.length === 1 && g.expansions[0] === 'ix' &&
|
||||
g.players.length >= 2 && g.players.length <= 6 &&
|
||||
@@ -9898,8 +9899,12 @@ function ixianNexusBetrayalModeSupported(g: Game): boolean {
|
||||
function ixianNexusBetrayalContext(g: Game) {
|
||||
return {status:g.status,phase:g.phase,turn:g.turn,advanced:g.advanced,
|
||||
sequence:g.nexusIxianBetrayalCursor!.sequence,players:g.players,
|
||||
...(g.ecazTreachery ? {expansions:g.expansions,ecazTreachery:true} : {}),
|
||||
cards:g.nexusCards!.cards!,physicalCards:physicalTreacheryCards(g)};
|
||||
}
|
||||
function ixianNexusCardConfiguration(g: Game) {
|
||||
return g.ecazTreachery ? {expansions:g.expansions,ecazTreachery:true,richese:!!byFaction(g,'richese')} : undefined;
|
||||
}
|
||||
function ixianNexusBetrayalAuthority(g: Game, response: ResponseWindow) {
|
||||
requireRule(response.kind === 'ixAuction' || response.kind === 'ixTechnology',
|
||||
'The saved Nexus continuation is not an original native Ixian window.');
|
||||
@@ -9926,6 +9931,9 @@ function ixianNexusBetrayalParent(g: Game, response: ResponseWindow): string {
|
||||
forces:p.forces,reserves:p.reserves,tanks:p.tanks,elites:p.elites,advisors:p.advisors})),
|
||||
deck:g.deck,discard:g.discard,aid:g.aid,nexus:g.nexusCards,decision:g.decision,response:g.response,
|
||||
phaseOpening:g.phaseOpening,truthtrance:g.truthtrance,pendingKarama:g.pendingKarama,
|
||||
...(g.ecazTreachery ? {selectedDecks:g.expansions,ecazTreachery:true,
|
||||
richeseCache:g.richeseCache,richeseRemoved:g.richeseRemoved,richeseAuction:g.richeseAuction,
|
||||
homeworlds:g.homeworlds} : {}),
|
||||
pendingNullentropy:g.pendingNullentropy,pendingTreacheryDiscard:g.pendingTreacheryDiscard});
|
||||
}
|
||||
function ixianNexusBetrayalIntegrity(g: Game): void {
|
||||
@@ -9940,7 +9948,7 @@ function ixianNexusBetrayalIntegrity(g: Game): void {
|
||||
Array.isArray(g.nexusIxianBetrayalHistory), 'The native Ixian Nexus profile lost its history or cursor.');
|
||||
nexusCardsIntegrity(g);
|
||||
nexusRule(() => validateIxianBetrayalHistory(g.nexusIxianBetrayalHistory!,
|
||||
g.nexusIxianBetrayalCursor!,physicalTreacheryCards(g)));
|
||||
g.nexusIxianBetrayalCursor!,physicalTreacheryCards(g),ixianNexusCardConfiguration(g)));
|
||||
if (!frame) {
|
||||
if (g.ixAuction?.cards.length === 0 || g.pendingIxTechnology) {
|
||||
const continuation = g.pendingTreacheryDiscard?.continuation;
|
||||
@@ -9978,7 +9986,7 @@ function offerIxianNexusBetrayal(g: Game,response: ResponseWindow): boolean {
|
||||
// finish. That spent attempt fizzles without exposing a Nexus offer.
|
||||
if (!getPlayer(g, response.owner).hand.some(c => c.id === g.pendingIxTechnology!.card)) return false;
|
||||
}
|
||||
const required = nexusRule(() => ixianBetrayalResponders(g.nexusCards!.cards!,g.players));
|
||||
const required = nexusRule(() => ixianBetrayalResponders(g.nexusCards!.cards!,g.players,ixianNexusCardConfiguration(g)));
|
||||
if (!required.length) return false;
|
||||
const source = nexusRule(() => createIxianBetrayalSource(ixianNexusBetrayalContext(g),
|
||||
ixianNexusBetrayalAuthority(g,response),ixianNexusBetrayalParent(g,response)));
|
||||
@@ -9989,7 +9997,7 @@ function ixianNexusBetrayalView(g: Game,id: string) {
|
||||
const frame = g.pendingNexusIxianBetrayal;
|
||||
if (!frame) return null;
|
||||
const hasPassed = frame.passed.includes(id), canPass = frame.required.includes(id) && !hasPassed;
|
||||
const eligible = canPass && nexusRule(() => ixianBetrayalEligible(g.nexusCards!.cards!,g.players,id));
|
||||
const eligible = canPass && nexusRule(() => ixianBetrayalEligible(g.nexusCards!.cards!,g.players,id,ixianNexusCardConfiguration(g)));
|
||||
return {event:frame.source.event,kind:frame.source.kind,provider:frame.source.provider,
|
||||
canPass,hasPassed,canUse:eligible,blocked:null as string | null};
|
||||
}
|
||||
@@ -10002,7 +10010,7 @@ function decideIxianNexusBetrayal(g: Game,id: string,action: Action): void {
|
||||
frame.required.includes(id) && !frame.passed.includes(id),
|
||||
'A current required seat must acknowledge only the original Ixian event.');
|
||||
const use = action.type === 'nexusIxianBetrayalUse';
|
||||
if (use) requireRule(nexusRule(() => ixianBetrayalEligible(g.nexusCards!.cards!,g.players,id)),
|
||||
if (use) requireRule(nexusRule(() => ixianBetrayalEligible(g.nexusCards!.cards!,g.players,id,ixianNexusCardConfiguration(g))),
|
||||
'You do not own an eligible unallied Ixian Nexus card.');
|
||||
if (!use) {
|
||||
frame.passed.push(id);
|
||||
@@ -10037,7 +10045,7 @@ export function initializeIxianNexusReplacementGameForAudit(state: Game): Game {
|
||||
return g;
|
||||
}
|
||||
function ixianNexusReplacementModeSupported(g: Game): boolean {
|
||||
if (classicNexusCoreProfile(g)) return true;
|
||||
if (nexusCoreProfile(g)) return !byFaction(g, 'ixians');
|
||||
return !!g.nexusCards && g.players.length >= 2 && g.players.length <= 6 &&
|
||||
new Set(g.players.map(p => p.faction)).size === g.players.length &&
|
||||
((!g.expansions.length && g.players.every(p => CLASSIC_FACTIONS[p.faction] === true)) ||
|
||||
@@ -10221,7 +10229,7 @@ export function initializeGuildBetrayalLeaderSkillsGameForAudit(state: Game): Ga
|
||||
return initializeLeaderSkillsGameForAudit(g);
|
||||
}
|
||||
function guildBetrayalModeSupported(g: Game): boolean {
|
||||
if (classicNexusCoreProfile(g)) return !!byFaction(g, 'guild');
|
||||
if (nexusCoreProfile(g)) return !!byFaction(g, 'guild');
|
||||
return !!g.nexusCards && g.expansions.length === 0 && g.players.length >= 2 &&
|
||||
g.players.length <= 6 && !!byFaction(g, 'guild') &&
|
||||
g.players.every(p => CLASSIC_FACTIONS[p.faction] === true) &&
|
||||
@@ -10248,6 +10256,11 @@ function guildBetrayalParentSignature(g: Game, continuation: GuildBetrayalContin
|
||||
pendingGuildTransport: g.pendingGuildTransport,
|
||||
...(cunning ? { nexusGuildCunning: cunning } : {}),
|
||||
...(richese ? { nexusRichese: richese } : {}),
|
||||
...(g.ecazTreachery && g.expansions.length ? {
|
||||
nativeMarkers:g.players.map(p => ({id:p.id,noField:p.noField,noFieldEvent:p.noFieldEvent,
|
||||
noFieldBlockedTurn:p.noFieldBlockedTurn})),
|
||||
richeseCunning:g.nexusRicheseCunningHistory, richeseAllyOffer:g.richeseAllyOffer,
|
||||
} : {}),
|
||||
...(g.techTokens ? { techTokens: g.techTokens } : {}),
|
||||
...(g.strongholdCards ? { strongholdCards: g.strongholdCards } : {}),
|
||||
});
|
||||
@@ -10273,6 +10286,7 @@ function guildBetrayalAuthority(g: Game, continuation: GuildBetrayalContinuation
|
||||
Object.keys(continuation).sort().join(',') === producerKeys[continuation.source],
|
||||
'The saved Guild shipment producer contains foreign action fields.');
|
||||
let shipper: string, price: number, allyPayment: number, originalReceiver: string | null;
|
||||
let approvedSupplier: {donor:string; amount:number} | null = null;
|
||||
const guild = byFaction(g, 'guild')!;
|
||||
switch (continuation.source) {
|
||||
case 'reserve': {
|
||||
@@ -10283,19 +10297,44 @@ function guildBetrayalAuthority(g: Game, continuation: GuildBetrayalContinuation
|
||||
'smuggler', 'smugglerCompanion', 'guildSecretEvent', 'guildNexusEvent', 'nexusEvent',
|
||||
'richesePair', 'noField', 'alliedNoField', 'noFieldSkillProof'].includes(key)),
|
||||
'The saved reserve shipment contains foreign source fields.');
|
||||
requireRule(!shipment.source && !shipment.noField && !shipment.alliedNoField &&
|
||||
!shipment.richesePair && !shipment.guildSecretEvent &&
|
||||
((shipment.smuggler && classicNexusCoreProfile(g)) || (!shipment.smuggler && !shipment.smugglerCompanion)) &&
|
||||
((!shipment.guildNexusEvent && !shipment.nexusEvent) || classicNexusCoreProfile(g)),
|
||||
requireRule(!shipment.source && !shipment.guildSecretEvent &&
|
||||
((!shipment.noField && !shipment.alliedNoField && !shipment.richesePair) || nexusCoreProfile(g)) &&
|
||||
(nexusCoreProfile(g) || (!shipment.smuggler && !shipment.smugglerCompanion)) &&
|
||||
((!shipment.guildNexusEvent && !shipment.nexusEvent) || nexusCoreProfile(g)),
|
||||
'This Guild Betrayal profile requires a supported original physical shipment source.');
|
||||
validatePhysicalShipment(g, shipment);
|
||||
validateShipmentArrival(g, shipment);
|
||||
checkShipmentIncomeFunding(g, getPlayer(g, shipment.player), shipment.cost, shipment.allyPayment);
|
||||
const p = getPlayer(g, shipment.player);
|
||||
if (shipment.noField) {
|
||||
requireRule(!shipment.alliedNoField && !shipment.nexusEvent && !shipment.guildNexusEvent &&
|
||||
p.faction === 'richese' && p.noField && p.noFieldEvent === shipment.noField.event &&
|
||||
!p.noField.deployed && g.active === p.id && shipmentAvailable(g,p) && shipment.amount === 1 &&
|
||||
shipment.elite === 0 && shipment.advisors === false,
|
||||
'The funded No-Field invoice needs its original unused native marker.');
|
||||
noFieldRule(() => deployRicheseNoField(p.noField!, {tokenId:shipment.noField!.tokenId,
|
||||
controller:p.id,location:{territory:shipment.territory,sector:shipment.sector}}));
|
||||
const cost = reserveShipmentCost({faction:p.faction,halfRate:shipmentHalfRate(g,p)},
|
||||
territory(shipment.territory).type,1);
|
||||
requireRule(shipment.cost === cost, 'The native No-Field invoice changed its one-marker price.');
|
||||
} else if (shipment.alliedNoField) {
|
||||
requireRule(!shipment.nexusEvent && !shipment.guildNexusEvent && !shipment.richesePair &&
|
||||
!shipment.smuggler && !shipment.smugglerCompanion && shipment.allyPayment === 0 &&
|
||||
g.active === p.id && shipmentAvailable(g,p),
|
||||
'An allied No-Field invoice needs its original supplier-approved source.');
|
||||
const quote = alliedNoFieldQuote(g,shipment.alliedNoField);
|
||||
requireRule(quote.amount === shipment.amount && quote.cost === shipment.cost &&
|
||||
shipment.elite >= quote.eliteMin && shipment.elite <= quote.eliteMax &&
|
||||
shipment.advisors === quote.advisors,
|
||||
'The allied No-Field allocation changed before funded payment.');
|
||||
approvedSupplier = {donor:shipment.alliedNoField.owner,amount:quote.ownerPayment};
|
||||
}
|
||||
if (!approvedSupplier) checkShipmentIncomeFunding(g, p, shipment.cost, shipment.allyPayment);
|
||||
checkShipmentPromises(g, getPlayer(g, shipment.player), shipment);
|
||||
shipper = shipment.player; price = shipment.cost; allyPayment = shipment.allyPayment;
|
||||
const p = getPlayer(g, shipper);
|
||||
shipper = shipment.player; price = shipment.cost;
|
||||
allyPayment = approvedSupplier?.amount ?? shipment.allyPayment;
|
||||
originalReceiver = guildShipmentIncome({guild: guild.id, shipper, ally: p.ally,
|
||||
cost: price, allyPayment, bankOnly: g.karamaShipping?.player === shipper}) > 0 ? guild.id : null;
|
||||
cost: price, allyPayment, bankOnly: g.karamaShipping?.player === shipper ||
|
||||
(!!shipment.alliedNoField && alliedNoFieldPricing(g,p).bankOnly)}) > 0 ? guild.id : null;
|
||||
break;
|
||||
}
|
||||
case 'guildTransport': {
|
||||
@@ -10317,7 +10356,7 @@ function guildBetrayalAuthority(g: Game, continuation: GuildBetrayalContinuation
|
||||
['player', 'destination', 'sources', 'route', 'event', 'turn', 'amount', 'elite', 'cost',
|
||||
'allyPayment', 'pools', 'guildSecretEvent', 'guildNexusEvent'].includes(key)),
|
||||
'The saved Homeworld shipment contains foreign source fields.');
|
||||
requireRule(!shipment.guildSecretEvent && (!shipment.guildNexusEvent || classicNexusCoreProfile(g)),
|
||||
requireRule(!shipment.guildSecretEvent && (!shipment.guildNexusEvent || nexusCoreProfile(g)),
|
||||
'This Guild Betrayal profile requires a supported original Homeworld shipment source.');
|
||||
const quote = validateHomeworldShipment(g, shipment);
|
||||
shipper = shipment.player; price = quote.cost; allyPayment = shipment.allyPayment;
|
||||
@@ -10341,10 +10380,11 @@ function guildBetrayalAuthority(g: Game, continuation: GuildBetrayalContinuation
|
||||
default: throw new RuleError('The saved Guild Betrayal producer is unsupported.');
|
||||
}
|
||||
const p = getPlayer(g, shipper);
|
||||
contribution(g, p, price, allyPayment);
|
||||
if (!approvedSupplier) contribution(g, p, price, allyPayment);
|
||||
return {source: continuation.source, sourceSignature: guildBetrayalParentSignature(g, continuation),
|
||||
shipper, price, allyPayment, ownSpice: p.spice, allyEscrow: aidFor(g, p)?.amount ?? 0,
|
||||
donor: allyPayment ? p.ally! : null, originalReceiver};
|
||||
shipper, price, allyPayment, ownSpice: p.spice,
|
||||
allyEscrow: approvedSupplier?.amount ?? aidFor(g, p)?.amount ?? 0,
|
||||
donor: allyPayment ? (approvedSupplier?.donor ?? p.ally!) : null, originalReceiver};
|
||||
}
|
||||
function guildBetrayalFrameSignature(frame: NonNullable<Game['pendingGuildBetrayal']>): string {
|
||||
return JSON.stringify([frame.invoice.signature, frame.invoice.sourceSignature, frame.required, frame.passed]);
|
||||
@@ -10755,7 +10795,7 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game {
|
||||
const homeworlds = homeworldLeaderSkillsProfile(state);
|
||||
const independent = independentTreacheryLeaderSkillsProfile(state);
|
||||
if (state.ecazTreachery) {
|
||||
requireRule(independent, 'Independent Skills cards require supported selected decks with original World/phase modules, or classic Nexus with original World/phase modules; unrelated preview contexts remain gated.');
|
||||
requireRule(independent, 'Independent Skills cards require supported selected decks with original World/phase modules and supported Nexus selections; unrelated preview contexts remain gated.');
|
||||
requireFreshBaseRuntime(state);
|
||||
requireFreshFactionInventory(state);
|
||||
}
|
||||
@@ -10775,7 +10815,7 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game {
|
||||
const classicNexus = classicNexusLeaderSkillsProfile(g);
|
||||
const pairedE3 = pairedE3NexusLeaderSkillsProfile(g);
|
||||
const mixedE3 = mixedE3NexusLeaderSkillsProfile(g);
|
||||
const initialized = initializeSetupGameForAudit(g, homeworlds, classicNexus || pairedNexusLeaderSkillsProfile(g) ||
|
||||
const initialized = initializeSetupGameForAudit(g, homeworlds, nexusCoreProfile(g) || classicNexus || pairedNexusLeaderSkillsProfile(g) ||
|
||||
singleE1E2NexusLeaderSkillsProfile(g) || mixedE1E2NexusLeaderSkillsProfile(g) ||
|
||||
mixedE3 || standaloneE3NexusLeaderSkillsProfile(g) || pairedE3 || guildBetrayalNexusLeaderSkillsProfile(g), false,
|
||||
classicDiscoveryLeaderSkillsProfile(g) || nativeDiscoveryLeaderSkillsProfile(g) || (g.discoveryEnabled === true && classicNexus), true,
|
||||
@@ -10802,7 +10842,7 @@ function independentCardModuleRoster(g: Game): boolean {
|
||||
(g.expansions.length === 1 && g.expansions[0] === 'ecaz'));
|
||||
}
|
||||
function ecazHomeworldProfile(g: Game): boolean {
|
||||
if (g.homeworlds && classicIndependentNexusProfile(g)) return true;
|
||||
if (g.homeworlds && independentNexusProfile(g)) return true;
|
||||
if (g.homeworlds && g.leaderSkills && !g.sandtrout && independentTreacheryLeaderSkillsProfile(g)) return true;
|
||||
return !!g.homeworlds && g.ecazTreachery === true && independentCardModuleRoster(g) &&
|
||||
g.players.length >= 2 && g.players.length <= 6 &&
|
||||
@@ -10818,7 +10858,7 @@ function ecazHomeworldProfile(g: Game): boolean {
|
||||
}
|
||||
/** Independent cards compose original phase modules, not unrelated previews. */
|
||||
function independentCardModulesProfile(g: Game): boolean {
|
||||
if ((g.techTokens || g.strongholdCards) && classicIndependentNexusProfile(g)) return true;
|
||||
if ((g.techTokens || g.strongholdCards) && independentNexusProfile(g)) return true;
|
||||
if ((g.techTokens || g.strongholdCards) && g.leaderSkills && !g.sandtrout &&
|
||||
independentTreacheryLeaderSkillsProfile(g)) return true;
|
||||
if (!g.ecazTreachery || (!g.techTokens && !g.strongholdCards) || !independentCardModuleRoster(g) ||
|
||||
@@ -10842,8 +10882,8 @@ export function initializeEcazTreacheryGameForAudit(state: Game): Game {
|
||||
g.ecazTreachery = true;
|
||||
const homeworlds = !!g.homeworlds;
|
||||
requireRule(!homeworlds || ecazHomeworldProfile(g),
|
||||
'Independent World cards need a supported selected-deck roster and original phase modules, or classic Nexus with original phase modules.');
|
||||
const initialized = initializeSetupGameForAudit(g, homeworlds, classicIndependentNexusProfile(g), false, false, false, false, true, true);
|
||||
'Independent World cards need a supported selected-deck roster with original phase modules and supported Nexus selections.');
|
||||
const initialized = initializeSetupGameForAudit(g, homeworlds, independentNexusProfile(g), false, false, false, false, true, true);
|
||||
if (byFaction(initialized, 'ecaz')) initialized.ecazOccupyPreview = true;
|
||||
if (moritaniAssassinateModeSupported(initialized)) initializeMoritaniAssassinateState(initialized);
|
||||
return initialized;
|
||||
@@ -10865,7 +10905,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
|
||||
classicDiscoveryNexusProfile(g) || pairedDiscoveryNexusProfile(g) ||
|
||||
mixedE3DiscoveryNexusProfile(g) || mixedE1E2DiscoveryNexusProfile(g) || pairedE3DiscoveryNexusProfile(g) ||
|
||||
(leaderSkills && (classicDiscoveryLeaderSkillsProfile(g) || nativeDiscoveryLeaderSkillsProfile(g) || classicNexusLeaderSkillsProfile(g))));
|
||||
const nexusModules = nexus && (classicNexusModulesProfile(g) || pairedNexusModulesProfile(g) ||
|
||||
const nexusModules = nexus && (nexusCoreProfile(g) || classicNexusModulesProfile(g) || pairedNexusModulesProfile(g) ||
|
||||
mixedE3NexusProfile(g) || singleE1E2NexusProfile(g) || mixedE1E2NexusProfile(g) || pairedE3NexusProfile(g) || standaloneE3NexusProfile(g) ||
|
||||
(leaderSkills && (classicNexusLeaderSkillsProfile(g) || pairedNexusLeaderSkillsProfile(g) ||
|
||||
mixedE3NexusLeaderSkillsProfile(g) || singleE1E2NexusLeaderSkillsProfile(g) || mixedE1E2NexusLeaderSkillsProfile(g) ||
|
||||
@@ -10915,7 +10955,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
|
||||
g.spiceBankerIncomePreview = true;
|
||||
g.spiceBankerIncome = bankerIncomeRule(() => createBankerIncomeState(bankerIncomeContext(g)));
|
||||
}
|
||||
if (classicNexusCoreProfile(g)) {
|
||||
if (nexusCoreProfile(g)) {
|
||||
requireRule(g.nexusIxianReplacementPreview === undefined &&
|
||||
g.nexusIxianReplacementHistory === undefined && g.nexusIxianReplacementCursor === undefined &&
|
||||
!g.pendingNexusIxianReplacement && !g.pendingNexusIxianSpecialPurchase &&
|
||||
@@ -10925,11 +10965,22 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
|
||||
g.guildBetrayalPreview === undefined && g.guildBetrayal === undefined &&
|
||||
g.nexusGuildBetrayalHistory === undefined && g.guildBetrayalSourceReceipts === undefined &&
|
||||
!g.pendingGuildBetrayal,
|
||||
'Native Nexus reactions initialize only with fresh classic setup.');
|
||||
'Native Nexus reactions initialize only with fresh supported core setup.');
|
||||
requireRule(g.nexusIxianBetrayalPreview === undefined && g.nexusIxianBetrayalHistory === undefined &&
|
||||
g.nexusIxianBetrayalCursor === undefined && !g.pendingNexusIxianBetrayal,
|
||||
'Native Ixian Nexus reactions require original undealt effect state.');
|
||||
if (!byFaction(g, 'ixians')) {
|
||||
g.nexusIxianReplacementPreview = true;
|
||||
g.pendingNexusIxianReplacement = null;
|
||||
g.nexusIxianReplacementHistory = [];
|
||||
g.nexusIxianReplacementCursor = initialIxianReplacementCursor();
|
||||
}
|
||||
if (byFaction(g, 'ixians')) {
|
||||
g.nexusIxianBetrayalPreview = true;
|
||||
g.pendingNexusIxianBetrayal = null;
|
||||
g.nexusIxianBetrayalHistory = [];
|
||||
g.nexusIxianBetrayalCursor = initialIxianBetrayalCursor();
|
||||
}
|
||||
if (byFaction(g, 'harkonnen')) {
|
||||
g.nexusHarkonnenBetrayalPreview = true;
|
||||
g.pendingNexusHarkonnenBetrayal = null;
|
||||
@@ -13942,7 +13993,7 @@ function recruitsModeSupported(g: Game) {
|
||||
const modules = independentCardModulesProfile(g);
|
||||
return (
|
||||
(!g.homeworlds || ecazHomeworldProfile(g)) &&
|
||||
(!g.nexusCards || classicIndependentNexusProfile(g)) &&
|
||||
(!g.nexusCards || independentNexusProfile(g)) &&
|
||||
(!g.leaderSkills || independentTreacheryLeaderSkillsProfile(g)) &&
|
||||
(!g.techTokens || modules) &&
|
||||
(!g.strongholdCards || modules) &&
|
||||
@@ -28136,7 +28187,7 @@ function projectedNexusGuildCunning(g: Game, owner: string) {
|
||||
hajrAvailable:record.stage !== 'pending' && !g.hajr.includes(owner)}};
|
||||
if (g.nexusCards?.cards?.hands[owner] !== 'guild') return null;
|
||||
let blocked: string | null = null;
|
||||
if (g.guildBetrayalPreview && !classicNexusCoreProfile(g)) blocked = 'This payment audit profile supports the original native shipment, not a Cunning second-shipment overlay.';
|
||||
if (g.guildBetrayalPreview && !nexusCoreProfile(g)) blocked = 'This payment audit profile supports the original native shipment, not a Cunning second-shipment overlay.';
|
||||
else if (p.ally) blocked = 'Guild Cunning requires an unallied native Guild player.';
|
||||
else if (g.status !== 'playing' || g.phase !== 5 || g.active !== owner)
|
||||
blocked = 'Use Cunning when finishing your own Shipment and Movement turn.';
|
||||
@@ -28338,7 +28389,7 @@ function nexusRicheseOffer(g: Game, owner: string) {
|
||||
const p = g.players.find(p => p.id === owner);
|
||||
if (!p || g.nexusCards?.cards?.hands[owner] !== 'richese' || byFaction(g,'richese')) return null;
|
||||
let blocked: string | null = null;
|
||||
if (g.guildBetrayalPreview && !classicNexusCoreProfile(g)) blocked = 'This payment audit profile supports the original native shipment, not a Richese Secret Ally shipment overlay.';
|
||||
if (g.guildBetrayalPreview && !nexusCoreProfile(g)) blocked = 'This payment audit profile supports the original native shipment, not a Richese Secret Ally shipment overlay.';
|
||||
else if (p.ally) blocked = 'Richese Secret Ally requires an unallied holder.';
|
||||
else if (g.status !== 'playing' || g.phase !== 5 || g.active !== owner || p.shipped)
|
||||
blocked = 'Use Secret Ally for your unused reserve shipment before moving.';
|
||||
@@ -28353,6 +28404,7 @@ const RICHESE_CUNNING_ROSTER: Partial<Record<FactionId, true>> = {
|
||||
beneGesserit: true, guild: true, richese: true, choam: true,
|
||||
};
|
||||
function richeseCunningModeSupported(g: Game) {
|
||||
if (nexusCoreProfile(g)) return !!byFaction(g, 'richese');
|
||||
const mixed = mixedE3NexusProfile(g) || mixedE1E2NexusProfile(g) ||
|
||||
(!!g.leaderSkills && (mixedE3NexusLeaderSkillsProfile(g) || mixedE1E2NexusLeaderSkillsProfile(g)));
|
||||
if (mixed) return !g.sandtrout;
|
||||
@@ -28441,7 +28493,10 @@ function finishRichesePairShipment(g: Game, shipment: PendingShipment, stage: 's
|
||||
}
|
||||
function richesePairIntegrity(g: Game) {
|
||||
const history = g.nexusRicheseCunningHistory;
|
||||
const pending = g.pendingShipment;
|
||||
const invoice = g.pendingGuildBetrayal?.continuation;
|
||||
const invoicedPair = invoice?.source === 'reserve' && invoice.shipment.richesePair ? invoice.shipment : null;
|
||||
const pending = g.pendingShipment ?? invoicedPair;
|
||||
requireRule(!g.pendingShipment || !invoicedPair, 'Richese Cunning cannot own two shipment continuations.');
|
||||
if (history === undefined) {
|
||||
requireRule(!g.nexusRicheseCunningLast && !pending?.richesePair,
|
||||
'Richese Cunning lost its saved shipment history.');
|
||||
@@ -28483,8 +28538,8 @@ function richesePairIntegrity(g: Game) {
|
||||
currentRichesePairShipment(g, pending);
|
||||
const responses = savedNoFieldResponses(g).filter(response => response.kind === 'richeseNoField');
|
||||
const decisions = homeworldSavedDecisions(g).filter(decision => decision.kind === 'guildShipment');
|
||||
requireRule(responses.length + decisions.length === 1,
|
||||
'Richese Cunning lost its unique Karama response or Guild interception decision.');
|
||||
requireRule(responses.length + decisions.length + Number(!!invoicedPair) === 1,
|
||||
'Richese Cunning lost its unique Karama, native Guild interception or funded invoice continuation.');
|
||||
for (const response of responses)
|
||||
requireRule(response.owner === pending.player && response.noFieldSkillProof === undefined,
|
||||
'Richese Cunning changed its native No-Field response.');
|
||||
@@ -30606,6 +30661,12 @@ function applyActionInner(
|
||||
decideNexusEmperorAmbassadorPurchase(g, p, action);
|
||||
return g;
|
||||
}
|
||||
// Conversion spends the selected card as fuel; its printed effect must not
|
||||
// intercept that explicit mode (including Recruits and other special faces).
|
||||
if (t === 'card' && action.mode === 'choam') {
|
||||
playChoamWorthless(g, p, action);
|
||||
return g;
|
||||
}
|
||||
if (t === 'card' && action.card === RECRUITS_CARD_ID) {
|
||||
requireRule(
|
||||
Object.keys(action).sort().join(',') === 'card,type',
|
||||
@@ -30845,10 +30906,6 @@ function applyActionInner(
|
||||
}
|
||||
return g;
|
||||
}
|
||||
if (t === 'card' && action.mode === 'choam') {
|
||||
playChoamWorthless(g, p, action);
|
||||
return g;
|
||||
}
|
||||
if (t === 'card' && action.mode === 'special') {
|
||||
requireRule(
|
||||
!g.choamMarket,
|
||||
@@ -31196,7 +31253,9 @@ function applyActionInner(
|
||||
// Verify the entire arrival continuation before the recipient commits.
|
||||
// Unsupported simultaneous reactions leave this offer declineable.
|
||||
const trial = structuredClone(g);
|
||||
commitShipment(trial, trial.pendingShipment!);
|
||||
// Preflight the native arrival, not an invoice inside this still-owned
|
||||
// acceptance decision. The real commit opens its invoice after counters.
|
||||
commitShipment(trial, trial.pendingShipment!, {recipient:null});
|
||||
g.richeseAllyOffer = null;
|
||||
g.response = { kind: 'richeseNoField', owner: offer.owner, passed: [] };
|
||||
log(
|
||||
|
||||
@@ -26,36 +26,65 @@ export type FactionModuleProfile = {
|
||||
nexusHarkonnenBetrayalPreview?: unknown;
|
||||
};
|
||||
|
||||
type ClassicNexusProfile = Partial<FactionModuleProfile> & Pick<FactionModuleProfile, 'expansions'> & {
|
||||
type NexusCoreConfiguration = Partial<FactionModuleProfile> & Pick<FactionModuleProfile, 'expansions'> & {
|
||||
sandtrout?: unknown; moritaniAssassinatePreview?: unknown;
|
||||
};
|
||||
|
||||
/** Classic Nexus12 with selected base33/independent36, optional all14, original
|
||||
* Homeworlds and phase modules. Native effect stamps are not selected overlays. */
|
||||
export function classicNexusCoreProfile(game: ClassicNexusProfile): boolean {
|
||||
export function classicNexusCoreProfile(game: NexusCoreConfiguration): boolean {
|
||||
return nexusCoreRosterProfile(game, false);
|
||||
}
|
||||
|
||||
/** Selected E1/E2 decks with the independent three-card variant. A deck selection
|
||||
* does not require seating its native faction. Original optional modules compose. */
|
||||
export function selectedIndependentNexusProfile(game: NexusCoreConfiguration): boolean {
|
||||
return game.ecazTreachery === true && game.expansions.length > 0 && nexusCoreRosterProfile(game, true);
|
||||
}
|
||||
|
||||
/** Common consumers of the classic and selected-independent fresh Nexus core. */
|
||||
export function nexusCoreProfile(game: NexusCoreConfiguration): boolean {
|
||||
return classicNexusCoreProfile(game) || selectedIndependentNexusProfile(game);
|
||||
}
|
||||
|
||||
function nexusCoreRosterProfile(game: NexusCoreConfiguration, selectedDecks: boolean): boolean {
|
||||
const players = game.players;
|
||||
if (typeof game.advanced !== 'boolean' || !game.nexusCards ||
|
||||
game.expansions.length !== 0 || !players || players.length < (game.techTokens ? 3 : 2) || players.length > 6 ||
|
||||
!players || players.length < (game.techTokens ? 3 : 2) || players.length > 6 ||
|
||||
(game.strongholdCards && !game.advanced) || game.sandtrout ||
|
||||
game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker ||
|
||||
game.semutaPreview || game.moritaniAssassinatePreview || game.advancedPreview || game.kullPreview || game.nexusKullPreview ||
|
||||
game.richeseBetrayalPreview ||
|
||||
game.nexusIxianBetrayalPreview) return false;
|
||||
(game.nexusIxianBetrayalPreview && (!selectedDecks || !players.some(player => player.faction === 'ixians')))) return false;
|
||||
if (selectedDecks) {
|
||||
if (game.expansions.length > 2) return false;
|
||||
for (let i = 0; i < game.expansions.length; i++) {
|
||||
if (game.expansions[i] !== 'ix' && game.expansions[i] !== 'choam') return false;
|
||||
for (let j = 0; j < i; j++) if (game.expansions[j] === game.expansions[i]) return false;
|
||||
}
|
||||
} else if (game.expansions.length !== 0) return false;
|
||||
for (let i = 0; i < players.length; i++) {
|
||||
for (let j = 0; j < i; j++) if (players[j].faction === players[i].faction) return false;
|
||||
let classic = false;
|
||||
let permitted = false;
|
||||
for (const faction of FACTIONS)
|
||||
if (faction.id === players[i].faction) { classic = faction.expansion === 'base'; break; }
|
||||
if (!classic) return false;
|
||||
if (faction.id === players[i].faction) {
|
||||
permitted = faction.expansion === 'base' || (selectedDecks && game.expansions.includes(faction.expansion));
|
||||
break;
|
||||
}
|
||||
if (!permitted) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Card consumers require the actual independent-variant selection. */
|
||||
export function classicIndependentNexusProfile(game: ClassicNexusProfile): boolean {
|
||||
export function classicIndependentNexusProfile(game: NexusCoreConfiguration): boolean {
|
||||
return game.ecazTreachery === true && classicNexusCoreProfile(game);
|
||||
}
|
||||
|
||||
export function independentNexusProfile(game: NexusCoreConfiguration): boolean {
|
||||
return game.ecazTreachery === true && nexusCoreProfile(game);
|
||||
}
|
||||
|
||||
/** Original E1/E2 families or standalone Ecaz/Moritani; explicit Semuta context only. */
|
||||
export function nativeFactionTechProfile(game: FactionModuleProfile, semuta = false): boolean {
|
||||
if (typeof game.advanced !== 'boolean' || !game.techTokens ||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FACTIONS } from './catalog';
|
||||
import { classicIndependentNexusProfile, classicNexusCoreProfile, mixedE3NexusRosterProfile } from './faction-module-profile';
|
||||
import { independentNexusProfile, classicNexusCoreProfile, nexusCoreProfile, mixedE3NexusRosterProfile } from './faction-module-profile';
|
||||
import { classicDiscoveryLeaderSkillsProfile } from './discovery-module-profile';
|
||||
|
||||
/** Public configuration only; never gate a skill on a hidden card or random deal. */
|
||||
@@ -68,7 +68,7 @@ export function classicNexusLeaderSkillsProfile(game: LeaderSkillProfile): boole
|
||||
|
||||
/** Fresh classic Nexus/Skills composed only with the bounded Guild Betrayal preview. */
|
||||
export function guildBetrayalNexusLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
|
||||
if (game.guildBetrayalPreview && classicNexusCoreProfile(game))
|
||||
if (game.guildBetrayalPreview && nexusCoreProfile(game))
|
||||
return game.players!.some(player => player.faction === 'guild');
|
||||
if (game.expansions.length || !game.guildBetrayalPreview || game.homeworlds ||
|
||||
game.techTokens || game.strongholdCards || game.semutaPreview ||
|
||||
@@ -463,7 +463,7 @@ function homeworldLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean
|
||||
/** Original Homeworld/Skills under the existing classic or native Nexus roster contracts. */
|
||||
export function homeworldLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
|
||||
if (!game.homeworlds) return false;
|
||||
if (classicNexusCoreProfile(game)) return true;
|
||||
if (nexusCoreProfile(game)) return true;
|
||||
if (game.expansions.length) return nativeExpansionLeaderSkillsProfile(game);
|
||||
if (!homeworldLeaderSkillModulesSupported(game)) return false;
|
||||
const players = game.players!;
|
||||
@@ -479,7 +479,7 @@ export function homeworldLeaderSkillsProfile(game: LeaderSkillProfile): boolean
|
||||
|
||||
/** Independent cards with registered selected-family all14 setup and original module/role guards. */
|
||||
export function independentTreacheryLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
|
||||
if (classicIndependentNexusProfile(game)) return true;
|
||||
if (independentNexusProfile(game)) return true;
|
||||
if (game.ecazTreachery !== true || game.expansions.length > 3 ||
|
||||
typeof game.advanced !== 'boolean' || !game.players ||
|
||||
game.players.length < (game.techTokens ? 3 : 2) || game.players.length > 6 ||
|
||||
|
||||
@@ -18,7 +18,7 @@ import type { RicheseAuction } from './richese-auction';
|
||||
import { quoteRicheseSettlement, type RicheseSettlementQuote } from './richese-settlement';
|
||||
import { quoteRicheseBetrayalInvoice } from './nexus-richese-betrayal';
|
||||
import { RICHESE_CARD_DEFINITIONS, richeseCardDefinition } from './richese-cards';
|
||||
import { classicNexusCoreProfile } from './faction-module-profile';
|
||||
import { nexusCoreProfile } from './faction-module-profile';
|
||||
|
||||
export type EmperorNexusBankPurchase = {
|
||||
kind: 'purchase';
|
||||
@@ -381,7 +381,7 @@ function nativeBankNexusProfile(g: Game): boolean {
|
||||
* with the Emperor absent, the concealed face and any buyer wallet or hand left
|
||||
* to the caller. */
|
||||
export function emperorNexusBankPurchaseModeSupported(g: Game): boolean {
|
||||
if (classicNexusCoreProfile(g))
|
||||
if (nexusCoreProfile(g))
|
||||
return !g.players.some(p => p.faction === 'emperor') && !g.sandtrout;
|
||||
if (!g.nexusCards || g.players.some(p => p.faction === 'emperor') ||
|
||||
g.ecazTreachery || g.sandtrout) return false;
|
||||
@@ -391,6 +391,8 @@ export function emperorNexusBankPurchaseModeSupported(g: Game): boolean {
|
||||
/** Reuse native Nexus profiles; the existing Richese Betrayal preview composes
|
||||
* only on its original bare paired CHOAM/Richese roster, not arbitrary overlays. */
|
||||
export function emperorNexusSellerPurchaseModeSupported(g: Game): boolean {
|
||||
if (nexusCoreProfile(g))
|
||||
return !g.players.some(p => p.faction === 'emperor') && g.players.some(p => p.faction === 'richese');
|
||||
if (!g.nexusCards || g.players.some(p => p.faction === 'emperor') ||
|
||||
!g.players.some(p => p.faction === 'richese') || g.ecazTreachery || g.sandtrout)
|
||||
return false;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Game, Player } from './engine';
|
||||
import { eliteRevivalRemaining, revivalPrevented } from './revival';
|
||||
import { FACTIONS } from './catalog';
|
||||
import { classicNexusCoreProfile } from './faction-module-profile';
|
||||
import { nexusCoreProfile } from './faction-module-profile';
|
||||
import {
|
||||
classicNexusModulesProfile,
|
||||
pairedNexusModulesProfile,
|
||||
@@ -206,7 +206,7 @@ export function validateEmperorNexusRevival(
|
||||
|
||||
/** Reuse original fresh roster/module predicates, independently of purchase admission. */
|
||||
function revivalModeSupported(g: Game): boolean {
|
||||
if (classicNexusCoreProfile(g)) return true;
|
||||
if (nexusCoreProfile(g)) return true;
|
||||
if (g.semutaPreview || g.advancedPreview || g.kullPreview || g.nexusKullPreview ||
|
||||
g.guildBetrayalPreview || g.richeseBetrayalPreview || g.nexusIxianReplacementPreview ||
|
||||
g.nexusIxianBetrayalPreview || g.nexusHarkonnenBetrayalPreview || g.ecazTreachery)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Game } from './engine';
|
||||
import { nexusCleanPlayBlocked } from './nexus-play-boundary';
|
||||
import { classicNexusCoreProfile } from './faction-module-profile';
|
||||
import { nexusCoreProfile } from './faction-module-profile';
|
||||
|
||||
export type FremenNexusBetrayal = {
|
||||
event: string;
|
||||
@@ -40,7 +40,7 @@ export function fremenBetrayalOffer(
|
||||
if (holder.ally) blocked = 'An allied player cannot use a Nexus card.';
|
||||
else if (g.status !== 'playing' || (g.phase !== 1 && g.phase !== 5))
|
||||
blocked = 'Use Fremen Betrayal before the first Spice Blow or before Fremen movement.';
|
||||
else if (!classicNexusCoreProfile(g) &&
|
||||
else if (!nexusCoreProfile(g) &&
|
||||
(g.expansions.length || g.homeworlds || g.leaderSkills || g.discoveryEnabled ||
|
||||
g.discoveries || g.discoveryStash || g.greatMaker || g.ecazTreachery ||
|
||||
g.techTokens || g.strongholdCards ||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { treacheryDeck, type Card } from './cards';
|
||||
import { FACTIONS } from './catalog';
|
||||
import { ecazTreacheryCards } from './ecaz-cards';
|
||||
import { richeseCards } from './richese-cards';
|
||||
import {
|
||||
nexusCardMode, validateNexusCards, validateNexusPlayers,
|
||||
type NexusPlayer, type NexusState,
|
||||
@@ -15,7 +18,17 @@ export type IxianBetrayalAuthority = {
|
||||
nativeRequired: readonly string[];
|
||||
nativePassed: readonly string[];
|
||||
};
|
||||
export type IxianBetrayalContext = {
|
||||
/** Public selections, never inferred from a hidden hand or a historical receipt.
|
||||
* Omission retains the original native Ixian deck and roster contract. */
|
||||
export type IxianBetrayalSelection = {
|
||||
expansions?: readonly string[];
|
||||
ecazTreachery?: boolean;
|
||||
};
|
||||
export type IxianBetrayalPhysicalSelection = IxianBetrayalSelection & {
|
||||
/** History has no live roster; its caller supplies public native Richese presence. */
|
||||
richese?: boolean;
|
||||
};
|
||||
export type IxianBetrayalContext = IxianBetrayalSelection & {
|
||||
status: string;
|
||||
phase: number;
|
||||
turn: number;
|
||||
@@ -52,12 +65,7 @@ export class IxianBetrayalError extends Error {
|
||||
this.name = 'IxianBetrayalError';
|
||||
}
|
||||
}
|
||||
const canonical = new Map(treacheryDeck(['ix']).map(card => [card.id, card]));
|
||||
const genesis = 'ixian-betrayal:0';
|
||||
const permitted: Readonly<Record<string, true>> = {
|
||||
atreides: true, harkonnen: true, emperor: true, fremen: true, guild: true,
|
||||
beneGesserit: true, ixians: true, tleilaxu: true,
|
||||
};
|
||||
const plain = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value) &&
|
||||
[Object.prototype, null].includes(Object.getPrototypeOf(value));
|
||||
@@ -79,36 +87,59 @@ function members(value: unknown): value is readonly string[] {
|
||||
function sameMembers(one: readonly string[], two: readonly string[]): boolean {
|
||||
return one.length === two.length && one.every((id, index) => id === two[index]);
|
||||
}
|
||||
function canonicalCard(value: unknown): value is Card {
|
||||
function selectedExpansions(selection: IxianBetrayalSelection): readonly string[] {
|
||||
const expansions = selection.expansions ?? ['ix'];
|
||||
requireBetrayal(Array.isArray(expansions) && expansions.length >= 1 && expansions.length <= 2 &&
|
||||
expansions.includes('ix') && expansions.every((expansion, index) =>
|
||||
(expansion === 'ix' || expansion === 'choam') && expansions.indexOf(expansion) === index) &&
|
||||
(selection.ecazTreachery === undefined || typeof selection.ecazTreachery === 'boolean'),
|
||||
'Ixian Betrayal requires an original selected Ixian deck, optionally CHOAM and independent cards.');
|
||||
return expansions;
|
||||
}
|
||||
function canonicalCard(value: unknown, canonical: ReadonlyMap<string, Card>): value is Card {
|
||||
if (!plain(value) || !text(value.id)) return false;
|
||||
const expected = canonical.get(value.id);
|
||||
return !!expected && exact(value, ['id', 'name', 'kind',
|
||||
...(expected.effect === undefined ? [] : ['effect'])]) &&
|
||||
value.name === expected.name && value.kind === expected.kind && value.effect === expected.effect;
|
||||
}
|
||||
function validatePhysical(cards: readonly Card[]): void {
|
||||
function validatePhysical(cards: readonly Card[], selection: IxianBetrayalPhysicalSelection = {}): void {
|
||||
const expansions = selectedExpansions(selection);
|
||||
requireBetrayal((selection.richese === undefined || typeof selection.richese === 'boolean') &&
|
||||
(!selection.richese || expansions.includes('choam')),
|
||||
'Ixian Betrayal needs the selected CHOAM deck for its native Richese cache.');
|
||||
const canonical = new Map([
|
||||
...treacheryDeck(expansions),
|
||||
...(selection.ecazTreachery ? ecazTreacheryCards() : []),
|
||||
...(selection.richese ? richeseCards() : []),
|
||||
].map(card => [card.id, card]));
|
||||
requireBetrayal(Array.isArray(cards) && cards.length === canonical.size &&
|
||||
cards.every(canonicalCard) && new Set(cards.map(card => card.id)).size === canonical.size,
|
||||
'Ixian Betrayal requires the complete canonical Ixian deck in unique physical custody.');
|
||||
cards.every(card => canonicalCard(card, canonical)) && new Set(cards.map(card => card.id)).size === canonical.size,
|
||||
'Ixian Betrayal requires the complete canonical selected deck and native cache in unique physical custody.');
|
||||
}
|
||||
function validateRoster(players: readonly NexusPlayer[]): void {
|
||||
function validateRoster(players: readonly NexusPlayer[], selection: IxianBetrayalPhysicalSelection = {}): void {
|
||||
const expansions = selectedExpansions(selection);
|
||||
validateNexusPlayers(players);
|
||||
requireBetrayal(players.every(player => Object.hasOwn(permitted, player.faction)) &&
|
||||
players.filter(player => player.faction === 'ixians').length === 1,
|
||||
'Ixian Betrayal needs its native Ixian and permitted classic or Tleilaxu seats.');
|
||||
requireBetrayal(players.every(player => FACTIONS.some(faction => faction.id === player.faction &&
|
||||
(faction.expansion === 'base' || expansions.includes(faction.expansion)))) &&
|
||||
players.filter(player => player.faction === 'ixians').length === 1 &&
|
||||
(selection.richese === undefined || selection.richese === players.some(player => player.faction === 'richese')),
|
||||
'Ixian Betrayal needs its native Ixian and registered classic or selected E1/E2 seats.');
|
||||
}
|
||||
|
||||
/** A neutral acknowledgment depends on public held-card presence, never its face. */
|
||||
export function ixianBetrayalResponders(cards: NexusState, players: readonly NexusPlayer[]): string[] {
|
||||
validateRoster(players);
|
||||
export function ixianBetrayalResponders(cards: NexusState, players: readonly NexusPlayer[],
|
||||
selection: IxianBetrayalPhysicalSelection = {}): string[] {
|
||||
validateRoster(players, selection);
|
||||
validateNexusCards(cards, players);
|
||||
return players.filter(player => !player.ally && player.faction !== 'ixians' &&
|
||||
cards.hands[player.id] !== null).map(player => player.id);
|
||||
}
|
||||
export function ixianBetrayalEligible(
|
||||
cards: NexusState, players: readonly NexusPlayer[], holder: string,
|
||||
selection: IxianBetrayalPhysicalSelection = {},
|
||||
): boolean {
|
||||
validateRoster(players);
|
||||
validateRoster(players, selection);
|
||||
validateNexusCards(cards, players);
|
||||
const seat = players.find(player => player.id === holder);
|
||||
return !!seat && !seat.ally && seat.faction !== 'ixians' && cards.hands[holder] === 'ixians' &&
|
||||
@@ -167,9 +198,9 @@ function validateContext(ctx: IxianBetrayalContext): void {
|
||||
requireBetrayal(plain(ctx) && ctx.status === 'playing' && ctx.phase === 3 && whole(ctx.turn) &&
|
||||
ctx.turn > 0 && whole(ctx.sequence) && typeof ctx.advanced === 'boolean',
|
||||
'Ixian Betrayal needs its current Bidding phase and independent cursor.');
|
||||
validateRoster(ctx.players);
|
||||
validateRoster(ctx.players, ctx);
|
||||
validateNexusCards(ctx.cards, ctx.players);
|
||||
validatePhysical(ctx.physicalCards);
|
||||
validatePhysical(ctx.physicalCards, {...ctx, richese: ctx.players.some(player => player.faction === 'richese')});
|
||||
}
|
||||
export function validateIxianBetrayalSource(
|
||||
ctx: IxianBetrayalContext, source: IxianBetrayalSource,
|
||||
@@ -187,7 +218,7 @@ export function validateIxianBetrayalSource(
|
||||
source.nativeWindow === authority.nativeWindow && source.nativeContext === authority.nativeContext &&
|
||||
sameMembers(source.nativeRequired, authority.nativeRequired) &&
|
||||
sameMembers(source.nativePassed, authority.nativePassed) &&
|
||||
sameMembers(source.required, ixianBetrayalResponders(ctx.cards, ctx.players)) &&
|
||||
sameMembers(source.required, ixianBetrayalResponders(ctx.cards, ctx.players, ctx)) &&
|
||||
source.eligible === (ctx.players.find(player => !player.ally && player.faction !== 'ixians' &&
|
||||
ctx.cards.hands[player.id] === 'ixians')?.id ?? null),
|
||||
'Ixian Betrayal no longer matches its current native attempt, parent or public responders.');
|
||||
@@ -201,7 +232,7 @@ export function createIxianBetrayalSource(
|
||||
...authority, nativeRequired: [...authority.nativeRequired], nativePassed: [...authority.nativePassed],
|
||||
version: 1, event: ixianBetrayalEvent(ctx.turn, ctx.sequence, authority.kind, authority.provider),
|
||||
turn: ctx.turn, phase: 3, advanced: ctx.advanced, sequence: ctx.sequence,
|
||||
required: ixianBetrayalResponders(ctx.cards, ctx.players),
|
||||
required: ixianBetrayalResponders(ctx.cards, ctx.players, ctx),
|
||||
eligible: ctx.players.find(player => !player.ally && player.faction !== 'ixians' &&
|
||||
ctx.cards.hands[player.id] === 'ixians')?.id ?? null,
|
||||
parent, signature: '',
|
||||
@@ -262,11 +293,12 @@ export function closeIxianBetrayalSource(
|
||||
export function validateIxianBetrayalHistory(
|
||||
history: readonly IxianBetrayalReceipt[], cursor: IxianBetrayalCursor,
|
||||
physical?: readonly Card[],
|
||||
selection: IxianBetrayalPhysicalSelection = {},
|
||||
): void {
|
||||
validateCursor(cursor);
|
||||
requireBetrayal(array(history) && history.length === cursor.sequence,
|
||||
'Ixian Betrayal history has missing or duplicate closed attempts.');
|
||||
if (physical !== undefined) validatePhysical(physical);
|
||||
if (physical !== undefined) validatePhysical(physical, selection);
|
||||
let previous = genesis, turn = 0;
|
||||
const events = new Set<string>();
|
||||
for (const [index, receipt] of history.entries()) {
|
||||
|
||||
+3
-3
@@ -4682,7 +4682,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
summary: 'A separate twelve-card deck rewards remaining unallied. Private draws and selected Atreides, Harkonnen, Tleilaxu, Ixian, Bene Gesserit, Emperor, Fremen, CHOAM, Moritani, Richese and Guild effects are connected. Other effects and complete module play remain in development.',
|
||||
steps: [
|
||||
'This optional module can be combined with Basic or Advanced rules independently of the factions and other expansion modules selected. Include one Nexus Card for each of the twelve factions.',
|
||||
'Fresh classic2–6 Basic/Advanced Nexus core admits base33 or selected independent36, optionally all14, original Homeworlds, Tech3+ and Advanced Strongholds2+. Actual Treachery, twelve Nexus, fourteen Skills, three Tech and six held-card stocks remain distinct; no selection is silently added. Original fresh Guild/Harkonnen/Ixian states use actual role/card authority. Starting hands/training, real closing draws, first-Storm tokens and end-Mentat claims stay native. Other families, Discovery/Semuta and reaction envelopes remain separate.',
|
||||
'Fresh classic2–6 Nexus core admits base33/independent36. Selected Ixian, CHOAM or combined decks also compose the independent three-card variant with Nexus12, optionally all14, original Homeworlds, Tech3+ and Advanced Strongholds2+. A selected deck need not seat its native faction. Ordinary stock is38/50 plus any native Richese cache10. Actual native Ixians get Betrayal; only their absence permits purchased-card Secret Ally replacement. Signed Richese two-marker shipments and actual Guild fees retain original continuation. Other E3, Discovery/Semuta and reaction envelopes remain separate; this is not complete Nexus acceptance.',
|
||||
'Recruits changes native Revival rates and the ordinary seven ceiling, not the fixed Tleilaxu Secret Ally group or its one-spice price. Five paid card counters may leave two ordinary free returns, using the one leader slot once. Emperor Cunning still makes five ordinary board counters temporarily Sardaukar: independent reserve fees use separate physical reserves, Harass returns actual undialed counters, and native Karama changes only the original Nexus effect. Native winner/inspection and Collection continue without extra cleanup prompts.',
|
||||
'At the end of the entire Spice Blow and Nexus phase, if a Nexus occurred and at least one alliance exists, each unallied player may draw one card or discard their held card and draw a replacement. You may decline. The two Advanced spice piles do not grant two closing draws.',
|
||||
'Keep your card secret until it is used. You may hold at most one Nexus Card. It is separate from your Treachery hand and does not occupy a Treachery hand slot.',
|
||||
@@ -4752,11 +4752,11 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
],
|
||||
related: ['ecaz-modules', 'spice-blow', 'alliance-funding', 'choam-worthless', 'choam-kull', 'nexus-choam-kull', 'nexus-richese-betrayal', 'nexus-guild-betrayal', 'nexus-ixian-replacement', 'nexus-ixian-betrayal'],
|
||||
checklist: [
|
||||
{area:"Implementation",status:'Partial',detail:"Native cross/return permission and rate remain separate; Cunning preserves its opportunity. Independent typed purchases and explicit immune Junction source choice retain their original tariffs/payees. Richese Secret Ally now keeps one billed counter and all five physical forces through native Guild rate allowance/cancellation; signed pending/settled sources stay bound. Unfunded release retains spent Nexus without debt/refund/reusable coupon and preserves ordinary shipment. Marker approvals still cannot silently increase; full acceptance remains open.",evidence:['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts', 'game/nexus-fremen-protection.ts', 'game/admin-seat-ai-shape.ts', 'game/richese-cancellation.ts', 'game/bot-mobility.ts', 'game/planetologist-movement.ts', 'game/revival.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally.ts', 'game/revival-resume.ts', 'game/revival-cancellation.ts', 'game/homeworld-revival-deployment.ts', 'game/homeworld-revival-return.ts', "game/nexus-emperor-purchase.ts", "game/auction-continuation-quote.ts", "game/nexus-emperor-purchase.ts", "game/ambassador-phase.ts", "game/ambassador-resume.ts", "game/battle-aftermath-quote.ts", "game/ecaz-duke-acquisition.ts", "game/ecaz-duke-revival.ts", "game/nexus-ecaz-duke-options.ts", "game/nexus-play-boundary.ts", "game/junction-transport-options.ts"]},
|
||||
{area:"Implementation",status:'Partial',detail:"Selected E1/E2 independent/Nexus now shares fresh setup and original native card/reaction consumers, including typed Ix fees/Harass/substitution, CHOAM fuel and Richese paired invoices. Native cross/return permission and rate remain separate; Cunning preserves its opportunity. Independent typed purchases and explicit immune Junction source choice retain their original tariffs/payees. Richese Secret Ally now keeps one billed counter and all five physical forces through native Guild rate allowance/cancellation; signed pending/settled sources stay bound. Unfunded release retains spent Nexus without debt/refund/reusable coupon and preserves ordinary shipment. Marker approvals still cannot silently increase; full acceptance remains open.",evidence:['game/faction-module-profile.ts','tests/selected-independent-nexus-entry.test.ts','tests/fixture-selected-independent-nexus-entry.ts','tests/selected-independent-nexus-battles.test.ts','tests/fixture-selected-independent-nexus-battles.ts','tests/selected-independent-nexus-cards.test.ts','tests/fixture-selected-independent-nexus-cards.ts','tests/selected-independent-ixian-betrayal.test.ts','game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts', 'game/nexus-fremen-protection.ts', 'game/admin-seat-ai-shape.ts', 'game/richese-cancellation.ts', 'game/bot-mobility.ts', 'game/planetologist-movement.ts', 'game/revival.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally.ts', 'game/revival-resume.ts', 'game/revival-cancellation.ts', 'game/homeworld-revival-deployment.ts', 'game/homeworld-revival-return.ts', "game/nexus-emperor-purchase.ts", "game/auction-continuation-quote.ts", "game/nexus-emperor-purchase.ts", "game/ambassador-phase.ts", "game/ambassador-resume.ts", "game/battle-aftermath-quote.ts", "game/ecaz-duke-acquisition.ts", "game/ecaz-duke-revival.ts", "game/nexus-ecaz-duke-options.ts", "game/nexus-play-boundary.ts", "game/junction-transport-options.ts"]},
|
||||
{area:"Player controls",status:'Partial',detail:"Four current original-action390px journeys prove native Junction5/full2, poor1/full2 release plus ordinary one-force replacement, still-held source sand2 disabled then five-force stronghold1, and independent purchased1. The response explains the spent-card consequence. No overflow, browser error or application exception; two tool-only stale-node errors were resolved by fresh evidence without replay. Earlier Junction/loan/refund surfaces remain scoped evidence.",evidence:['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx', 'components/nexus-fremen-revival.tsx','components/nexus-emperor-payment.tsx', 'components/nexus-emperor-secret-ally.tsx', "components/nexus-emperor-seller-purchase.tsx", "components/nexus-emperor-ambassador-purchase.tsx", "components/richese-special-karama.tsx", "components/junction-transport.tsx"]},
|
||||
{area:"AI",status:'Partial',detail:"All four original policies execute a legal still-held Richese full-rate one-counter alternative after native cancellation, with actual physical delivery and card cost. Shared human/AI quotes no longer promise a canceled discount. Native permission alternatives, recipient-specific lender prices and immune Junction source choice remain. Optional non-reoffer is not forced. No strategic first-candidate pin, tuning or calibration.",evidence:['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally-options.ts']},
|
||||
{area:"Documentation",status:'Partial',detail:"Richese printed one-counter pricing and native Guild eligibility stay distinct from absent-faction cancellation, physical force count and independent purchased price. Unfunded spent-card/no-refund release is selected existing no-debt composition, not a new publisher worked example. Immune Junction and earlier permission/refund/loan interpretations remain explicit. Full rules and public gates stay open.",evidence:['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/GUILD_RATE_KARAMA_RULES.md', 'docs/RICHESE_ALLIED_SHIPMENT_AUDIT.md', 'docs/GUILD_HOMEWORLD_TRANSPORT_RULES.md', 'docs/NEXUS_GUILD_RULES.md', "docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md", "docs/RULE_DECISIONS.md", "docs/RICHESE_AMBASSADOR_RULES.md", "docs/RICHESE_AMBASSADOR_IMPLEMENTATION.md", "docs/RICHESE_ACQUISITION_RULES.md", "docs/DUKE_VIDAL_RULES.md", "docs/ECAZ_DUKE_ACQUISITION.md", "docs/ECAZ_REVIVAL_RULES.md", "docs/JUNCTION_TRANSPORT_RULES.md", "docs/JUNCTION_TRANSPORT_RUNTIME.md"]},
|
||||
{area:"Verification",status:'Partial',detail:"Classic core retains original World/Tech/Stronghold modules with base33/selected36 and optional14. Independent World cases use native setup, population-sensitive Recruits, typed foreign Harass and actual own-Kaitain Reinforcements/Cunning. The local fee reduces only its paid temporary roles; original count5 and real stars remain. Earlier phase/card proofs are dated in the canonical contract. Native-family/Discovery/Semuta envelopes, full-mode and comprehensive assurance remain open.",evidence:['tests/nexus-world-independent-cards.test.ts','tests/nexus-world-independent-battles.test.ts','tests/reinforcements-temporary-role.test.ts','tests/nexus-core-tech-runtime.test.ts','tests/fixture-nexus-core-tech.ts','tests/nexus-core-strongholds-runtime.test.ts','tests/fixture-nexus-core-strongholds.ts','tests/classic-nexus-core.test.ts','tests/fixture-classic-nexus-core.ts','game/faction-module-profile.ts','tests/classic-independent-nexus-utility.test.ts', 'tests/classic-independent-nexus-battle.test.ts', 'tests/fixture-classic-independent-nexus.ts', 'tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/native-guild-rate-karama.test.ts', 'tests/native-guild-rate-modules.test.ts', 'tests/allied-marker-guild-rate.test.ts', 'tests/purchased-homeworld-rate.test.ts', 'tests/native-guild-transport-permission.test.ts', 'tests/native-guild-homeworld-rate.test.ts', 'tests/native-guild-rate-betrayal.test.ts', 'tests/nexus-guild-cunning-engine.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts', 'tests/nexus-fremen-protection.test.ts', 'tests/fixture-nexus-fremen-protection.ts', 'tests/nexus-fremen-protection-runtime.test.ts', 'tests/fixture-stronghold-factions.ts', 'tests/fixture-nexus-advisor-fresh.ts', 'tests/nexus-advisor-fresh-runtime.test.ts', 'tests/fixture-nexus-advisor-storm.ts', 'tests/nexus-advisor-storm-runtime.test.ts', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'tests/nexus-fremen-free-quota.test.ts', 'tests/fixture-nexus-fremen-free-quota.ts', 'tests/nexus-fremen-free-quota-runtime.test.ts', 'tests/nexus-fremen-revival.test.ts','tests/nexus-emperor-payment.test.ts','tests/fixture-nexus-emperor-payment.ts','tests/nexus-emperor-payment-runtime.test.ts','docs/NEXUS_EMPEROR_RULES.md', 'tests/nexus-emperor-extra-source.test.ts', 'tests/nexus-emperor-extra-continuation.test.ts', 'tests/fixture-nexus-empeLine truncated
|
||||
{area:"Verification",status:'Partial',detail:"Selected independent entry, native battle/card and canonical native-Ix cancellation cases now join original action paths; frozen proof is recorded in the canonical Nexus contract. Classic core retains original World/Tech/Stronghold modules with base33/selected36 and optional14. Independent World cases use native setup, population-sensitive Recruits, typed foreign Harass and actual own-Kaitain Reinforcements/Cunning. The local fee reduces only its paid temporary roles; original count5 and real stars remain. Earlier phase/card proofs are dated in the canonical contract. Native-family/Discovery/Semuta envelopes, full-mode and comprehensive assurance remain open.",evidence:['tests/nexus-world-independent-cards.test.ts','tests/nexus-world-independent-battles.test.ts','tests/reinforcements-temporary-role.test.ts','tests/nexus-core-tech-runtime.test.ts','tests/fixture-nexus-core-tech.ts','tests/nexus-core-strongholds-runtime.test.ts','tests/fixture-nexus-core-strongholds.ts','tests/classic-nexus-core.test.ts','tests/fixture-classic-nexus-core.ts','game/faction-module-profile.ts','tests/classic-independent-nexus-utility.test.ts', 'tests/classic-independent-nexus-battle.test.ts', 'tests/fixture-classic-independent-nexus.ts', 'tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/native-guild-rate-karama.test.ts', 'tests/native-guild-rate-modules.test.ts', 'tests/allied-marker-guild-rate.test.ts', 'tests/purchased-homeworld-rate.test.ts', 'tests/native-guild-transport-permission.test.ts', 'tests/native-guild-homeworld-rate.test.ts', 'tests/native-guild-rate-betrayal.test.ts', 'tests/nexus-guild-cunning-engine.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts', 'tests/nexus-fremen-protection.test.ts', 'tests/fixture-nexus-fremen-protection.ts', 'tests/nexus-fremen-protection-runtime.test.ts', 'tests/fixture-stronghold-factions.ts', 'tests/fixture-nexus-advisor-fresh.ts', 'tests/nexus-advisor-fresh-runtime.test.ts', 'tests/fixture-nexus-advisor-storm.ts', 'tests/nexus-advisor-storm-runtime.test.ts', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'tests/nexus-fremen-free-quota.test.ts', 'tests/fixture-nexus-fremen-free-quota.ts', 'tests/nexus-fremen-free-quota-runtime.test.ts', 'tests/nexus-fremen-revival.test.ts','tests/nexus-emperor-payment.test.ts','tests/fixture-nexus-emperor-payment.ts','tests/nexus-empLine truncated
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -22,6 +22,7 @@ export type IndependentCardModulesOptions = {
|
||||
/** False keeps the original native deck for revealed-card comparisons. */
|
||||
independent?: boolean;
|
||||
homeworlds?: boolean;
|
||||
nexus?: boolean;
|
||||
};
|
||||
|
||||
/** Select only the original undealt fourteen-card lottery, never assignments. */
|
||||
@@ -56,6 +57,7 @@ export function createIndependentCardModulesFixture(options: IndependentCardModu
|
||||
advanced, options.expansions ?? []);
|
||||
for (const faction of factions.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
|
||||
if (options.homeworlds) game = applyAction(game, game.host, { type: 'homeworlds', enabled: true });
|
||||
if (options.nexus) game = applyAction(game, game.host, { type: 'moduleOption', module: 'nexusCards', enabled: true });
|
||||
if (options.tech ?? true) game.techTokens = createTechTokens();
|
||||
if (options.strongholds) game.strongholdCards = createStrongholdCards();
|
||||
const steps: IndependentCardModulesStep[] = [];
|
||||
@@ -106,6 +108,13 @@ export function createIndependentCardModulesFixture(options: IndependentCardModu
|
||||
|
||||
/** Reuse original native choices, including all phase/module windows. */
|
||||
export function nextIndependentCardModulesStep(game: Game): IndependentCardModulesStep | null {
|
||||
if (!game.response && !game.phaseOpening && !game.decision && game.nexusCards?.phase?.stage === 'drawing') {
|
||||
const phase = game.nexusCards.phase;
|
||||
const actor = phase.eligible.find(id => !phase.done.includes(id));
|
||||
assert.ok(actor);
|
||||
return { actor, action: { type: 'nexusCardChoice', turn: game.turn,
|
||||
card: game.nexusCards.cards!.hands[actor] ?? null, choice: 'keep', ownRedraws: 0 } };
|
||||
}
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { territory } from '../game/board';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
|
||||
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
|
||||
import {
|
||||
createIndependentCardModulesFixture, type IndependentCardModulesOptions,
|
||||
type IndependentCardModulesStep,
|
||||
} from './fixture-independent-card-modules';
|
||||
import {
|
||||
applyClassicDiscoveryNexusStep, nextClassicDiscoveryNexusStep, withClassicDiscoveryNexusLottery,
|
||||
} from './fixture-discovery-classic-nexus';
|
||||
|
||||
export type SelectedIndependentNexusBattleStep = IndependentCardModulesStep;
|
||||
export type SelectedIndependentNexusBattleOptions = Pick<IndependentCardModulesOptions,
|
||||
'advanced' | 'expansions' | 'homeworlds' | 'skills' | 'tech' | 'strongholds'> & {
|
||||
program?: 'mixed-return' | 'substitution' | 'faceDance';
|
||||
};
|
||||
export const selectedIndependentNexusPlayer = (game: Game, actor: string) => {
|
||||
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
|
||||
};
|
||||
export const selectedIndependentNexusClean = (game: Game) => !game.phaseOpening && !game.response &&
|
||||
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard &&
|
||||
!game.pendingGuildBetrayal && !game.pendingNexusIxianBetrayal && !game.pendingNexusHarkonnenBetrayal;
|
||||
|
||||
/** Original native windows only. Sealed plans and the selected typed Harass
|
||||
* allocation remain explicit, and a Face Dance is declined unless requested. */
|
||||
export function nextSelectedIndependentNexusBattleStep(game: Game): SelectedIndependentNexusBattleStep | null {
|
||||
if (game.pendingGuildBetrayal) {
|
||||
const frame = game.pendingGuildBetrayal;
|
||||
const actor = frame.required.find(id => !frame.passed.includes(id)); assert.ok(actor);
|
||||
return { actor, action: { type: 'guildBetrayalPass', event: frame.invoice.event } };
|
||||
}
|
||||
if (!game.response && !game.phaseOpening) {
|
||||
if (game.decision?.kind === 'harassWithdraw') return null;
|
||||
if (game.pendingNexusIxianBetrayal) {
|
||||
const offer = game.pendingNexusIxianBetrayal;
|
||||
const actor = offer.required.find(id => !offer.passed.includes(id)); assert.ok(actor);
|
||||
return { actor, action: { type: 'nexusIxianBetrayalPass', event: offer.source.event } };
|
||||
}
|
||||
}
|
||||
// The nine-Suboid first shipment legitimately spends the final nine spice
|
||||
// after its paid opening bid. Basic has no later stronghold income: claim
|
||||
// the real next-turn charity before acknowledgement closes that phase.
|
||||
if (selectedIndependentNexusClean(game) && game.phase === 2) {
|
||||
const view = viewGame(game, 'ixians');
|
||||
const own = view.players.find(player => player.id === 'ixians')!;
|
||||
if (!own.charityClaimed && !view.charity.incomePending && view.charity.amount > 0)
|
||||
return { actor: 'ixians', action: { type: 'charity' } };
|
||||
}
|
||||
return nextClassicDiscoveryNexusStep(game);
|
||||
}
|
||||
export function actSelectedIndependentNexusBattle(game: Game, step: SelectedIndependentNexusBattleStep,
|
||||
actions?: SelectedIndependentNexusBattleStep[]): Game {
|
||||
return applyClassicDiscoveryNexusStep(game, step, actions);
|
||||
}
|
||||
export function advanceSelectedIndependentNexusBattle(game: Game, until: (state: Game) => boolean,
|
||||
actions: SelectedIndependentNexusBattleStep[] = []): Game {
|
||||
for (let count = 0; count < 2200; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'Original selected-family game ended before its requested rule window');
|
||||
const step = nextSelectedIndependentNexusBattleStep(game);
|
||||
assert.ok(step, `A real plan/allocation is required at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actSelectedIndependentNexusBattle(game, step, actions);
|
||||
}
|
||||
throw Error('Selected independent Nexus programme did not reach its native boundary');
|
||||
}
|
||||
|
||||
/** Reorder only the still-unopened physical Treachery deck. The Ixian native
|
||||
* pool returns a different card, then the owner buys the real desired lot. */
|
||||
function purchaseOriginalCard(game: Game, desired: string, actions: SelectedIndependentNexusBattleStep[], staging: string[]) {
|
||||
assert.equal(game.phase, 2);
|
||||
const at = game.deck.findIndex(card => card.id === desired);
|
||||
assert.ok(at >= 0, 'The targeted purchase must still be an undealt original card');
|
||||
game.deck.unshift(game.deck.splice(at, 1)[0]);
|
||||
staging.push(`Order unopened ${desired} before the original turn-${game.turn} Ixian auction pool; acquire it only through the printed auction and payment.`);
|
||||
let bid: { before: Game; step: SelectedIndependentNexusBattleStep; after: Game } | undefined;
|
||||
for (let count = 0; count < 350; count++) {
|
||||
if (selectedIndependentNexusPlayer(game, 'ixians').hand.some(card => card.id === desired)) {
|
||||
assert.ok(bid, 'The native hand must acquire its target by an actual paid bid');
|
||||
return { game, bid };
|
||||
}
|
||||
let step: SelectedIndependentNexusBattleStep | null = null;
|
||||
if (!game.response && !game.phaseOpening && game.decision?.kind === 'ixAuction') {
|
||||
const returned = game.ixAuction!.cards.find(card => card.id !== desired); assert.ok(returned);
|
||||
assert.ok(game.ixAuction!.cards.some(card => card.id === desired));
|
||||
step = { actor: 'ixians', action: { type: 'decision', card: returned.id, position: 'bottom' } };
|
||||
} else if (selectedIndependentNexusClean(game) && game.auction?.active === 'ixians' &&
|
||||
game.auction.cards[game.auction.index].id === desired && game.auction.bid === 0) {
|
||||
const before = structuredClone(game);
|
||||
step = { actor: 'ixians', action: { type: 'bid', amount: 1, allyPayment: 0 } };
|
||||
game = actSelectedIndependentNexusBattle(game, step, actions);
|
||||
bid = { before, step, after: structuredClone(game) };
|
||||
continue;
|
||||
}
|
||||
step ??= nextSelectedIndependentNexusBattleStep(game); assert.ok(step);
|
||||
game = actSelectedIndependentNexusBattle(game, step, actions);
|
||||
}
|
||||
throw Error('Original selected-family auction did not deliver its physical card');
|
||||
}
|
||||
|
||||
/** Fresh selected E1/E2 setup, original paid auctions and shipments, real
|
||||
* reciprocal alliance, closing draw and native battle. No dealt hand, board
|
||||
* counter, wallet, phase, Face Dancer, receipt or module flag is rewritten. */
|
||||
export function createSelectedIndependentNexusBattleFixture(options: SelectedIndependentNexusBattleOptions = {}) {
|
||||
const program = options.program ?? 'substitution', advanced = options.advanced ?? true;
|
||||
const initialize = () => createIndependentCardModulesFixture({ ...options, advanced,
|
||||
expansions: options.expansions ?? ['ix'], factions: ['ixians', 'tleilaxu', 'guild', 'emperor'],
|
||||
independent: true, nexus: true, tech: options.tech ?? false });
|
||||
// The constructor restores the caller's entropy method after its initializer;
|
||||
// retain the original scalar lottery through later setup actions as well.
|
||||
// finishSetup then shuffles the unopened native stock and deals its actual
|
||||
// Face Dancers; no already-dealt identity is exchanged for a desired one.
|
||||
const source = program === 'faceDance' ? withClassicDiscoveryNexusLottery(initialize) : initialize();
|
||||
let game = structuredClone(source.game);
|
||||
const afterSetup = structuredClone(game), actions = [...source.steps], staging: string[] = [];
|
||||
if (program === 'faceDance') staging.push('Select the original unopened final setup Face Dancer shuffle through the scalar lottery; retain the three genuinely dealt identities.');
|
||||
const act = (actor: string, action: Action) => {
|
||||
game = actSelectedIndependentNexusBattle(game, { actor, action }, actions);
|
||||
};
|
||||
const reach = (until: (state: Game) => boolean) => {
|
||||
game = advanceSelectedIndependentNexusBattle(game, until, actions);
|
||||
};
|
||||
// First turn has no worm. The ordinary second-turn worm opens the actual
|
||||
// alliance Nexus before the existing native closing-draw controller.
|
||||
const takeLand = () => {
|
||||
const at = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery);
|
||||
assert.ok(at >= 0); return game.spiceDeck.splice(at, 1)[0];
|
||||
};
|
||||
game.spiceDeck.unshift(takeLand(), ...(advanced ? [takeLand()] : []));
|
||||
staging.push('Position unopened first-turn ordinary land Spice Cards; later choose one original second-turn worm before it is drawn.');
|
||||
reach(state => state.phase === 2 && selectedIndependentNexusClean(state));
|
||||
const purchase = purchaseOriginalCard(game, REINFORCEMENTS_CARD, actions, staging); game = purchase.game;
|
||||
const purchases = [purchase.bid];
|
||||
const kind = 'arrakeen', sector = territory(kind).sectors[0], location = `${kind}:${sector}`;
|
||||
reach(state => state.phase === 5 && state.active === 'ixians' && selectedIndependentNexusClean(state));
|
||||
const amount = program === 'mixed-return' ? 9 : 6, elite = program === 'mixed-return' ? 0 : 3;
|
||||
const homeworldSources = game.homeworlds ? nativeShipmentSources(viewGame(game, 'ixians'), amount, elite) : undefined;
|
||||
assert.ok(!game.homeworlds || homeworldSources);
|
||||
const beforeShipment = structuredClone(game);
|
||||
const ownShipment: SelectedIndependentNexusBattleStep = { actor: 'ixians', action: {
|
||||
type: 'ship', territory: kind, sector, amount, elite, allyPayment: 0,
|
||||
...(homeworldSources ? { homeworldSources } : {}),
|
||||
} };
|
||||
game = actSelectedIndependentNexusBattle(game, ownShipment, actions);
|
||||
reach(state => selectedIndependentNexusClean(state));
|
||||
const afterShipment = structuredClone(game);
|
||||
reach(state => state.turn === 2 && state.phase === 1 && selectedIndependentNexusClean(state));
|
||||
const afterFirstMentat = structuredClone(game);
|
||||
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]);
|
||||
reach(state => state.nexus && !state.spiceWindow && !state.spiceResolution && selectedIndependentNexusClean(state));
|
||||
act('guild', { type: 'alliance', target: 'emperor' });
|
||||
act('emperor', { type: 'alliance', target: 'guild' });
|
||||
reach(state => state.nexusCards?.phase?.stage === 'drawing');
|
||||
const cards = game.nexusCards!.cards!, next = cards.deck.indexOf('ixians'); assert.ok(next >= 0);
|
||||
cards.deck.unshift(cards.deck.splice(next, 1)[0]);
|
||||
staging.push('Order only the still-undealt Ixian Nexus singleton; the unallied native receives it through its original closing draw.');
|
||||
const beforeNexusDraw = structuredClone(game);
|
||||
const drawStep: SelectedIndependentNexusBattleStep = { actor: 'ixians', action: {
|
||||
type: 'nexusCardChoice', turn: game.turn, card: cards.hands.ixians, choice: 'draw', ownRedraws: 0,
|
||||
} };
|
||||
game = actSelectedIndependentNexusBattle(game, drawStep, actions);
|
||||
const afterNexusDraw = structuredClone(game);
|
||||
if (program === 'mixed-return') {
|
||||
reach(state => state.phase === 2 && selectedIndependentNexusClean(state));
|
||||
const purchase = purchaseOriginalCard(game, HARASS_WITHDRAW_CARD, actions, staging);
|
||||
game = purchase.game; purchases.push(purchase.bid);
|
||||
}
|
||||
reach(state => state.phase === 5 && state.active === 'guild' && selectedIndependentNexusClean(state));
|
||||
const enemySources = game.homeworlds ? nativeShipmentSources(viewGame(game, 'guild'), 3, 0) : undefined;
|
||||
assert.ok(!game.homeworlds || enemySources);
|
||||
const enemyShipment: SelectedIndependentNexusBattleStep = { actor: 'guild', action: {
|
||||
type: 'ship', territory: kind, sector, amount: 3, elite: 0, allyPayment: 0,
|
||||
...(enemySources ? { homeworldSources: enemySources } : {}),
|
||||
} };
|
||||
game = actSelectedIndependentNexusBattle(game, enemyShipment, actions);
|
||||
reach(state => state.phase === 6 && selectedIndependentNexusClean(state));
|
||||
const beforeBattle = structuredClone(game), choices = viewGame(game, game.active!).battleChoices;
|
||||
const choice = choices.find(battle => battle.territory === kind); assert.ok(choice);
|
||||
const battleStep: SelectedIndependentNexusBattleStep = { actor: choice.chooser, action: {
|
||||
type: 'chooseBattle', territory: kind, target: choice.chooser === 'ixians' ? 'guild' : 'ixians',
|
||||
} };
|
||||
game = actSelectedIndependentNexusBattle(game, battleStep, actions);
|
||||
reach(state => !!state.battle && !state.battle.preparation && state.battle.preLeader?.closed !== false && selectedIndependentNexusClean(state));
|
||||
const ix = selectedIndependentNexusPlayer(game, 'ixians'), tleilaxu = selectedIndependentNexusPlayer(game, 'tleilaxu');
|
||||
const leaders = ix.leaders.filter(leader => !leader.dead && !leader.usedAt &&
|
||||
!game.leaderSkills?.assignments.some(assignment => assignment.leader === leader.id));
|
||||
const leader = (program === 'faceDance' ? leaders.filter(leader =>
|
||||
tleilaxu.faceDancers!.some(dancer => !dancer.revealed && dancer.leader === leader.id)) : leaders)
|
||||
.sort((one, two) => two.strength - one.strength)[0];
|
||||
assert.ok(leader, 'Use an actually dealt matching native Face Dancer; never exchange identities');
|
||||
const enemy = selectedIndependentNexusPlayer(game, 'guild').leaders.filter(leader => !leader.dead && !leader.usedAt &&
|
||||
!game.leaderSkills?.assignments.some(assignment => assignment.leader === leader.id))
|
||||
.sort((one, two) => one.strength - two.strength)[0]; assert.ok(enemy);
|
||||
const sources = viewGame(game, 'ixians').battle!.reinforcements!.homeworldSources?.[0];
|
||||
const plans: SelectedIndependentNexusBattleStep[] = [
|
||||
{ actor: 'ixians', action: { type: 'battlePlan', leader: leader.id,
|
||||
dial: program === 'mixed-return' ? 1 : 3, support: advanced && program !== 'mixed-return' ? 1 : 0,
|
||||
weapon: REINFORCEMENTS_CARD, defense: program === 'mixed-return' ? HARASS_WITHDRAW_CARD : null,
|
||||
...(sources ? { reinforcementsSources: sources } : {}),
|
||||
} },
|
||||
{ actor: 'guild', action: { type: 'battlePlan', leader: enemy.id, dial: 0, support: 0 } },
|
||||
];
|
||||
const normal = Math.min(3, ix.reserves - (ix.elites?.reserves ?? 0));
|
||||
return { ...source, afterSetup, afterFirstMentat, beforeShipment, ownShipment, afterShipment,
|
||||
beforeNexusDraw, drawStep, afterNexusDraw, enemyShipment, beforeBattle, battleStep,
|
||||
game, program, kind, sector, location, plans, actions, staging, purchases,
|
||||
leader: leader.id, fee: { normal, elite: 3 - normal },
|
||||
returns: program === 'mixed-return' ? { [location]: { normal: 8, elite: 0 } } : null };
|
||||
}
|
||||
|
||||
export function activateSelectedIndependentSuboids(game: Game, actions: SelectedIndependentNexusBattleStep[] = []): Game {
|
||||
const offer = viewGame(game, 'ixians').nexusSuboids!.offer!; assert.ok(offer && !offer.blocked);
|
||||
game = actSelectedIndependentNexusBattle(game, { actor: 'ixians', action: { type: 'nexusSuboids', event: offer.event } }, actions);
|
||||
return advanceSelectedIndependentNexusBattle(game, selectedIndependentNexusClean, actions);
|
||||
}
|
||||
export function revealSelectedIndependentNexusPlans(game: Game, plans: readonly SelectedIndependentNexusBattleStep[],
|
||||
actions: SelectedIndependentNexusBattleStep[] = []): Game {
|
||||
for (const step of plans) {
|
||||
game = actSelectedIndependentNexusBattle(game, step, actions);
|
||||
game = advanceSelectedIndependentNexusBattle(game, state => !state.response && !state.phaseOpening &&
|
||||
(!state.decision || state.decision.kind === 'harassWithdraw') &&
|
||||
!state.pendingNexusIxianBetrayal && !state.pendingGuildBetrayal, actions);
|
||||
}
|
||||
assert.ok(game.battle?.revealed); return game;
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { RECRUITS_CARD_ID } from '../game/recruits';
|
||||
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
|
||||
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
|
||||
import { createIndependentCardModulesFixture, type IndependentCardModulesOptions, type IndependentCardModulesStep } from './fixture-independent-card-modules';
|
||||
import { nextPairedChoamSkillsStep } from './fixture-paired-choam-nexus-skills';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
|
||||
export type SelectedIndependentNexusStep = IndependentCardModulesStep;
|
||||
export type SelectedIndependentNexusOptions = Pick<IndependentCardModulesOptions, 'advanced' | 'skills' | 'tech' | 'strongholds'> & {
|
||||
expansions?: Game['expansions'];
|
||||
/** A real first-turn bribe funds a paid ten-force turn-two shipment; the
|
||||
* original drawn Cunning is retained for turn three. */
|
||||
drainReserves?: boolean;
|
||||
choamNexus?: 'choam' | 'ixians';
|
||||
};
|
||||
export const selectedIndependentNexusClean = (game: Game) => !game.phaseOpening && !game.response && !game.decision &&
|
||||
!game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingGuildBetrayal &&
|
||||
!game.pendingNexusIxianBetrayal && !game.pendingNexusHarkonnenBetrayal;
|
||||
export function selectedIndependentNexusPlayer(game: Game, actor: string) {
|
||||
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
|
||||
}
|
||||
export function actSelectedIndependentNexus(game: Game, actor: string, action: Action, actions?: SelectedIndependentNexusStep[]): Game {
|
||||
actions?.push({ actor, action: structuredClone(action) });
|
||||
return withClassicDiscoveryNexusLottery(() => {
|
||||
// The original action owns all payment, marker, card and force transitions.
|
||||
// Stable scalar lotteries only order still-unused native shuffles.
|
||||
return applyAction(game, actor, action);
|
||||
});
|
||||
}
|
||||
|
||||
function nextSelectedIndependentNexus(game: Game): SelectedIndependentNexusStep | null {
|
||||
if (game.pendingGuildBetrayal) {
|
||||
const pending = game.pendingGuildBetrayal;
|
||||
const actor = pending.required.find(actor => !pending.passed.includes(actor)); assert.ok(actor);
|
||||
return { actor, action: { type: 'guildBetrayalPass', event: pending.invoice.event } };
|
||||
}
|
||||
if (game.pendingNexusIxianReplacement)
|
||||
return { actor: game.pendingNexusIxianReplacement.buyer,
|
||||
action: { type: 'nexusIxianReplacementPass', event: game.pendingNexusIxianReplacement.event } };
|
||||
if (!game.phaseOpening && !game.response && !game.truthtrance) {
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'homeworldDefense')
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event, use: false } };
|
||||
if (decision?.kind === 'homeworldRevivalDeployment' || decision?.kind === 'caladanReinforcement')
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } };
|
||||
if (decision?.kind === 'richeseDeclaration') return { actor: decision.player,
|
||||
action: { type: 'decision', event: game.richeseBidding!.event, position: 'first' } };
|
||||
if (decision?.kind === 'richeseBlackMarket') return { actor: decision.player,
|
||||
action: { type: 'decision', event: game.richeseBidding!.event, decline: true } };
|
||||
if (decision?.kind === 'richeseCache') {
|
||||
const card = game.richeseCache![0]; assert.ok(card);
|
||||
return { actor: decision.player, action: { type: 'decision', event: game.richeseBidding!.event,
|
||||
card: card.id, method: 'onceAround', direction: 'counterclockwise' } };
|
||||
}
|
||||
if (decision?.kind === 'richeseUnbid') return { actor: decision.player,
|
||||
action: { type: 'decision', event: game.richeseBidding!.event, keep: false } };
|
||||
if (!decision && game.richeseAuction && !game.richeseAuction.outcome) {
|
||||
const lot = game.richeseAuction;
|
||||
const actor = lot.method === 'silent'
|
||||
? lot.order.find(actor => lot.eligible.includes(actor) && !Object.hasOwn(lot.sealed, actor)) : lot.active;
|
||||
assert.ok(actor);
|
||||
return { actor, action: { type: 'richeseBid', event: lot.event, amount: lot.method === 'silent' ? 0 : null } };
|
||||
}
|
||||
}
|
||||
return nextPairedChoamSkillsStep(game);
|
||||
}
|
||||
export function advanceSelectedIndependentNexus(game: Game, until: (state: Game) => boolean,
|
||||
actions: SelectedIndependentNexusStep[] = [], basicStormDial?: number): Game {
|
||||
for (let count = 0; count < 2000; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The real selected-deck chronology ended before its card control');
|
||||
const step = nextSelectedIndependentNexus(game); assert.ok(step, 'Supply the current original human Battle Plan');
|
||||
const action = basicStormDial !== undefined && !game.advanced && step.action.type === 'stormDial'
|
||||
? {...step.action,amount:basicStormDial} : step.action;
|
||||
game = actSelectedIndependentNexus(game, step.actor, action, actions);
|
||||
}
|
||||
throw Error('The selected-deck independent/Nexus chronology did not reach its requested boundary');
|
||||
}
|
||||
function frontOriginalCard(game: Game, id: string): void {
|
||||
const index = game.deck.findIndex(card => card.id === id); assert.ok(index >= 0, `Original unused ${id} must remain in the deck`);
|
||||
game.deck.unshift(game.deck.splice(index, 1)[0]);
|
||||
}
|
||||
function orderOriginalBlow(game: Game, worm = false): void {
|
||||
const front: Game['spiceDeck'] = [];
|
||||
if (worm) {
|
||||
const index = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.thumper && !card.suppressed);
|
||||
assert.ok(index >= 0); front.push(game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
for (let count = 0; count < (game.advanced ? 2 : 1); count++) {
|
||||
const index = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery);
|
||||
assert.ok(index >= 0); front.push(game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
game.spiceDeck.unshift(...front);
|
||||
}
|
||||
function buyOriginalNormalCard(game: Game, actor: string, card: string, actions: SelectedIndependentNexusStep[]): Game {
|
||||
for (let count = 0; count < 500; count++) {
|
||||
if (selectedIndependentNexusPlayer(game, actor).hand.some(face => face.id === card)) return game;
|
||||
let step: SelectedIndependentNexusStep | null;
|
||||
if (selectedIndependentNexusClean(game) && game.auction && !game.richeseAuction &&
|
||||
game.auction.cards[game.auction.index]?.id === card) {
|
||||
const auction = game.auction;
|
||||
step = { actor: auction.active, action: auction.active === actor && auction.bidder !== actor
|
||||
? { type: 'bid', amount: 1, allyPayment: 0 } : { type: 'passBid' } };
|
||||
} else step = nextSelectedIndependentNexus(game);
|
||||
assert.ok(step, 'The original normal lot must expose its bid/payment control');
|
||||
game = actSelectedIndependentNexus(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error(`The real first-turn normal auction did not deliver ${card}`);
|
||||
}
|
||||
|
||||
/** Fresh selected deck + independent3 + Nexus12 + optional all14/phase modules.
|
||||
* Real first-turn normal purchases supply Recruits and Reinforcements; real
|
||||
* first marker/reveal supplies the later reserve budget. Nothing is granted to
|
||||
* a played hand, wallet, army or receipt. Only unused original stock is ordered. */
|
||||
export function createSelectedIndependentNexusCardsFixture(options: SelectedIndependentNexusOptions = {}) {
|
||||
const source = createIndependentCardModulesFixture({ ...options, nexus: true, independent: true,
|
||||
tech: options.tech ?? false, expansions: options.expansions ?? ['choam'], factions: ['richese', 'choam', 'guild', 'atreides'] });
|
||||
let game = structuredClone(source.game);
|
||||
const afterSetup = structuredClone(game), actions = source.steps;
|
||||
// The bank deals in front order. Both physical independent cards remain
|
||||
// unused after the native initial base/E1/E2 starting deals.
|
||||
frontOriginalCard(game, REINFORCEMENTS_CARD); frontOriginalCard(game, RECRUITS_CARD_ID);
|
||||
orderOriginalBlow(game);
|
||||
const staging = ['Order the unopened original Recruits and Reinforcements as the first two normal bank lots; buy each for one spice through native bidding.',
|
||||
'Order original non-worm first Blow and the original second-turn worm/land Blow before they are drawn.'];
|
||||
game = buyOriginalNormalCard(game, 'choam', RECRUITS_CARD_ID, actions);
|
||||
game = buyOriginalNormalCard(game, 'richese', REINFORCEMENTS_CARD, actions);
|
||||
const afterPurchases = structuredClone(game);
|
||||
game = advanceSelectedIndependentNexus(game, state => state.phase === 5 && state.active === 'richese' && selectedIndependentNexusClean(state), actions);
|
||||
const marker = selectedIndependentNexusPlayer(game, 'richese').noField!.tokens.find(token => token.value === 0); assert.ok(marker);
|
||||
const firstShipmentBefore = structuredClone(game);
|
||||
const firstShipmentStep: SelectedIndependentNexusStep = { actor: 'richese', action: { type: 'ship', noField: marker.id,
|
||||
event: selectedIndependentNexusPlayer(game, 'richese').noFieldEvent, territory: 'habbanya_ridge_sietch', sector: 17 } };
|
||||
game = actSelectedIndependentNexus(game, firstShipmentStep.actor, firstShipmentStep.action, actions);
|
||||
game = advanceSelectedIndependentNexus(game, selectedIndependentNexusClean, actions);
|
||||
const firstShipmentAfter = structuredClone(game);
|
||||
const deployed = selectedIndependentNexusPlayer(game, 'richese').noField!.deployed; assert.ok(deployed);
|
||||
game = actSelectedIndependentNexus(game, 'richese', { type: 'revealNoField',
|
||||
event: selectedIndependentNexusPlayer(game, 'richese').noFieldEvent, token: deployed.tokenId }, actions);
|
||||
const afterFirstReveal = structuredClone(game);
|
||||
let reserveFunding: { before: Game; step: SelectedIndependentNexusStep; after: Game } | null = null;
|
||||
if (options.drainReserves) {
|
||||
const before = structuredClone(game), step: SelectedIndependentNexusStep = {
|
||||
actor: 'choam', action: { type: 'bribe', target: 'richese', amount: 9 },
|
||||
};
|
||||
game = actSelectedIndependentNexus(game, step.actor, step.action, actions);
|
||||
reserveFunding = { before, step, after: structuredClone(game) };
|
||||
staging.push('CHOAM pays a real nine-spice turn-one bribe to Richese; its native END Mentat credit funds the later paid reserve-draining shipment.');
|
||||
}
|
||||
game = advanceSelectedIndependentNexus(game, state => state.turn === 2 && state.phase === 1 && selectedIndependentNexusClean(state), actions);
|
||||
orderOriginalBlow(game, true);
|
||||
game = advanceSelectedIndependentNexus(game, state => state.nexus && !state.spiceWindow && !state.spiceResolution && selectedIndependentNexusClean(state), actions);
|
||||
const beforeAlliance = structuredClone(game);
|
||||
for (const [actor, target] of [['guild', 'atreides'], ['atreides', 'guild']])
|
||||
game = actSelectedIndependentNexus(game, actor, { type: 'alliance', target }, actions);
|
||||
game = advanceSelectedIndependentNexus(game, state => state.nexusCards?.phase?.stage === 'drawing' && selectedIndependentNexusClean(state), actions);
|
||||
const beforeClosingDraw = structuredClone(game), draws: Array<{ before: Game; step: SelectedIndependentNexusStep; after: Game }> = [];
|
||||
for (const [actor, face] of [['richese', 'richese'], ['choam', options.choamNexus ?? 'choam']] as const) {
|
||||
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf(face); assert.ok(index >= 0);
|
||||
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
|
||||
const before = structuredClone(game), step: SelectedIndependentNexusStep = { actor,
|
||||
action: { type: 'nexusCardChoice', turn: game.turn, card: cards.hands[actor], choice: 'draw', ownRedraws: 0 } };
|
||||
game = actSelectedIndependentNexus(game, step.actor, step.action, actions);
|
||||
draws.push({ before, step, after: structuredClone(game) });
|
||||
}
|
||||
game = advanceSelectedIndependentNexus(game, state => state.nexusCards?.phase?.stage === 'complete' && selectedIndependentNexusClean(state), actions);
|
||||
assert.equal(game.nexusCards!.cards!.hands.richese, 'richese');
|
||||
assert.equal(game.nexusCards!.cards!.hands.choam, options.choamNexus ?? 'choam');
|
||||
return { ...source, afterSetup, afterPurchases, firstShipment: { before: firstShipmentBefore, step: firstShipmentStep, after: firstShipmentAfter },
|
||||
afterFirstReveal, reserveFunding, beforeAlliance, beforeClosingDraw, draws, game, actions, staging };
|
||||
}
|
||||
|
||||
export function selectedIndependentChoamFuelAction(game: Game): Action {
|
||||
const play = viewGame(game, 'choam').choamWorthless!.plays.find(play => play.source === 'nexus' &&
|
||||
play.card.id === RECRUITS_CARD_ID && play.effect === 'kulon');
|
||||
assert.ok(play && play.source === 'nexus' && !play.blocked);
|
||||
return { type: 'card', mode: 'choam', card: RECRUITS_CARD_ID, effect: 'kulon', nexus: play.event };
|
||||
}
|
||||
export function selectedIndependentRichesePairAction(game: Game): Action {
|
||||
const player = selectedIndependentNexusPlayer(game, 'richese'), offer = viewGame(game, 'richese').nexusRicheseCunning;
|
||||
assert.ok(offer && !offer.blocked);
|
||||
const concealed = player.noField!.tokens.find(token => token.value === 3), revealed = player.noField!.tokens.find(token => token.value === 5);
|
||||
assert.ok(concealed && revealed);
|
||||
return { type: 'ship', noField: concealed.id, revealedToken: revealed.id, nexus: offer.event,
|
||||
event: player.noFieldEvent, territory: 'tueks_sietch', sector: 5, allyPayment: 0 };
|
||||
}
|
||||
/** Real two-marker Cunning attacks the untouched native Guild starting army.
|
||||
* Optional paid ten-force ordinary shipment leaves two reserves after the
|
||||
* later concealed 3 appears; the canonical marker values remain 0/3/5. */
|
||||
export function createSelectedIndependentNexusBattleFixture(options: SelectedIndependentNexusOptions = {}) {
|
||||
const source = createSelectedIndependentNexusCardsFixture(options), actions = source.actions;
|
||||
let game = advanceSelectedIndependentNexus(source.game, state => state.phase === 5 && state.active === 'richese' && selectedIndependentNexusClean(state), actions);
|
||||
let reserveShipment: { before: Game; step: SelectedIndependentNexusStep; after: Game } | null = null;
|
||||
if (options.drainReserves) {
|
||||
const before = structuredClone(game), step: SelectedIndependentNexusStep = { actor: 'richese',
|
||||
action: { type: 'ship', amount: 10, elite: 0, territory: 'habbanya_ridge_sietch', sector: 17, allyPayment: 0 } };
|
||||
game = actSelectedIndependentNexus(game, step.actor, step.action, actions);
|
||||
game = advanceSelectedIndependentNexus(game, selectedIndependentNexusClean, actions);
|
||||
reserveShipment = { before, step, after: structuredClone(game) };
|
||||
assert.equal(selectedIndependentNexusPlayer(game, 'richese').reserves, 10);
|
||||
assert.equal(selectedIndependentNexusPlayer(game, 'richese').spice,
|
||||
selectedIndependentNexusPlayer(before, 'richese').spice - 10);
|
||||
assert.equal(game.nexusCards!.cards!.hands.richese, 'richese');
|
||||
game = advanceSelectedIndependentNexus(game, state => state.turn === 3 && state.phase === 1 && selectedIndependentNexusClean(state), actions, 2);
|
||||
orderOriginalBlow(game);
|
||||
game = advanceSelectedIndependentNexus(game, state => state.turn === 3 && state.phase === 5 && state.active === 'richese' && selectedIndependentNexusClean(state), actions);
|
||||
source.staging.push('Richese pays ten spice for an actual ten-force ordinary turn-two shipment to empty Habbanya, then retains its original Nexus until turn-three Cunning. Actual legal Basic Storm dials of two leave the protected Guild stronghold available for shipment.');
|
||||
}
|
||||
const beforePair = structuredClone(game), pairAction = selectedIndependentRichesePairAction(game);
|
||||
game = actSelectedIndependentNexus(game, 'richese', pairAction, actions);
|
||||
game = advanceSelectedIndependentNexus(game, selectedIndependentNexusClean, actions);
|
||||
const afterPair = structuredClone(game);
|
||||
game = advanceSelectedIndependentNexus(game, state => state.phase === 6 && !!state.active && selectedIndependentNexusClean(state), actions);
|
||||
const choice = viewGame(game, game.active!).battleChoices.find(choice => choice.territory === 'tueks_sietch'); assert.ok(choice);
|
||||
const battleAction: Action = { type: 'chooseBattle', territory: choice.territory,
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker };
|
||||
game = actSelectedIndependentNexus(game, choice.chooser, battleAction, actions);
|
||||
game = advanceSelectedIndependentNexus(game, state => !!state.battle && !state.battle.preparation &&
|
||||
state.battle.preLeader?.closed !== false && selectedIndependentNexusClean(state), actions);
|
||||
const available = (actor: string) => selectedIndependentNexusPlayer(game, actor).leaders.filter(leader => !leader.dead && !leader.usedAt &&
|
||||
!game.leaderSkills?.assignments.some(assignment => assignment.owner === actor && assignment.leader === leader.id))
|
||||
.sort((a, b) => a.strength - b.strength);
|
||||
const own = available('richese').at(-1), other = available('guild')[0]; assert.ok(own && other);
|
||||
const plans: SelectedIndependentNexusStep[] = [
|
||||
{ actor: 'richese', action: { type: 'battlePlan', leader: own.id, dial: 2, support: game.advanced ? 2 : 0,
|
||||
defense: REINFORCEMENTS_CARD } },
|
||||
{ actor: 'guild', action: { type: 'battlePlan', leader: other.id, dial: 0, support: 0 } },
|
||||
];
|
||||
return { ...source, game, actions, reserveShipment, beforePair, pairAction, afterPair, battleAction, plans, location: 'tueks_sietch:5' };
|
||||
}
|
||||
export function revealSelectedIndependentNexusPlans(game: Game, plans: readonly SelectedIndependentNexusStep[], actions: SelectedIndependentNexusStep[] = []): Game {
|
||||
for (const plan of plans) {
|
||||
game = actSelectedIndependentNexus(game, plan.actor, plan.action, actions);
|
||||
game = advanceSelectedIndependentNexus(game, state => !!state.battle && !state.battle.preparation &&
|
||||
state.battle.preLeader?.closed !== false && selectedIndependentNexusClean(state), actions);
|
||||
}
|
||||
assert.ok(game.battle?.revealed); return game;
|
||||
}
|
||||
|
||||
/** Actual compulsory cache auction, funded CHOAM bid and absent-Ix offer. The
|
||||
* discarded bought cache face is replaced by an original independent face. */
|
||||
export function createSelectedIndependentNexusCacheFixture(options: SelectedIndependentNexusOptions = {}) {
|
||||
const source = createSelectedIndependentNexusCardsFixture({ ...options, choamNexus: 'ixians' }), actions = source.actions;
|
||||
let game = advanceSelectedIndependentNexus(source.game, state => !state.phaseOpening && !state.response && state.decision?.kind === 'richeseCache', actions);
|
||||
const card = structuredClone(game.richeseCache![0]); assert.ok(card);
|
||||
game = actSelectedIndependentNexus(game, 'richese', { type: 'decision', event: game.richeseBidding!.event,
|
||||
card: card.id, method: 'silent' }, actions);
|
||||
game = advanceSelectedIndependentNexus(game, selectedIndependentNexusClean, actions);
|
||||
const beforePurchase = structuredClone(game), lot = game.richeseAuction; assert.ok(lot && !lot.outcome);
|
||||
frontOriginalCard(game, HARASS_WITHDRAW_CARD);
|
||||
const replacement = structuredClone(game.deck[0]);
|
||||
for (let count = 0; !game.pendingNexusIxianReplacement && count < 200; count++) {
|
||||
const current = game.richeseAuction;
|
||||
let step: SelectedIndependentNexusStep | null;
|
||||
if (selectedIndependentNexusClean(game) && current && !current.outcome) {
|
||||
const actor = current.order.find(actor => current.eligible.includes(actor) && !Object.hasOwn(current.sealed, actor)); assert.ok(actor);
|
||||
step = { actor, action: { type: 'richeseBid', event: current.event, amount: actor === 'choam' ? 2 : 0 } };
|
||||
} else step = nextSelectedIndependentNexus(game);
|
||||
assert.ok(step); game = actSelectedIndependentNexus(game, step.actor, step.action, actions);
|
||||
}
|
||||
const offer = viewGame(game, 'choam').nexusIxianReplacement;
|
||||
assert.ok(offer && offer.origin === 'cache' && offer.canUse && offer.purchased?.id === card.id);
|
||||
return { ...source, game, actions, beforePurchase, card, replacement, price: 2,
|
||||
useAction: { type: 'nexusIxianReplacementUse', event: offer.event } as Action };
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
|
||||
export type SelectedIndependentEntryOptions = {
|
||||
expansions: Game['expansions'];
|
||||
factions: readonly FactionId[];
|
||||
advanced?: boolean;
|
||||
skills?: boolean;
|
||||
homeworlds?: boolean;
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
};
|
||||
|
||||
/** Ordinary host controls and original private choices; no dealt inventory edits. */
|
||||
export function createSelectedIndependentEntry(options: SelectedIndependentEntryOptions) {
|
||||
const [host, ...others] = options.factions;
|
||||
let game = createGame('SELECTEDINDEPENDENTNEXUS', newPlayer(host, host, host), options.advanced ?? false, options.expansions);
|
||||
for (const faction of others) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const actions: {actor: string; action: Action}[] = [];
|
||||
const act = (actor: string, action: Action) => {
|
||||
actions.push(structuredClone({actor, action}));
|
||||
game = applyAction(game, actor, action);
|
||||
};
|
||||
act(host, {type:'moduleOption', module:'ecazTreachery', enabled:true});
|
||||
act(host, {type:'moduleOption', module:'nexusCards', enabled:true});
|
||||
if (options.skills) act(host, {type:'moduleOption', module:'leaderSkills', enabled:true});
|
||||
if (options.homeworlds) act(host, {type:'homeworlds', enabled:true});
|
||||
if (options.tech) act(host, {type:'techTokens', enabled:true});
|
||||
if (options.strongholds) act(host, {type:'strongholdCards', enabled:true});
|
||||
for (const player of game.players) act(player.id, {type:'ready'});
|
||||
const lobby = structuredClone(game);
|
||||
act(host, options.advanced ? {type:'start', advancedPreview:true} : {type:'start'});
|
||||
const setup = structuredClone(game);
|
||||
for (let count = 0; game.status === 'setup' && count < 100; count++) {
|
||||
let next: {actor:string; action:Action} | undefined;
|
||||
for (const player of game.players) {
|
||||
const view = viewGame(game, player.id);
|
||||
view.players.find(seat => seat.id === player.id)!.bot = 'Easy';
|
||||
const action = botActions(view)[0];
|
||||
if (action) { next = {actor:player.id, action}; break; }
|
||||
}
|
||||
assert.ok(next, `Original ${game.setupStage} setup must have an owned legal choice`);
|
||||
act(next.actor, next.action);
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
return {lobby, setup, game, actions};
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { ecazTreacheryCards } from '../game/ecaz-cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { NEXUS_FACTIONS, type NexusPlayer } from '../game/nexus-cards';
|
||||
import {
|
||||
IxianBetrayalError, closeIxianBetrayalSource, createIxianBetrayalSource,
|
||||
initialIxianBetrayalCursor, ixianBetrayalEligible, ixianBetrayalResponders,
|
||||
validateIxianBetrayalFrame, validateIxianBetrayalHistory,
|
||||
type IxianBetrayalAuthority, type IxianBetrayalContext,
|
||||
type IxianBetrayalPhysicalSelection,
|
||||
} from '../game/nexus-ixian-betrayal';
|
||||
|
||||
function fixture(mixed = true, advanced = true): IxianBetrayalContext {
|
||||
const players: NexusPlayer[] = mixed ? [
|
||||
{ id: 'ix', faction: 'ixians' }, { id: 'holder', faction: 'richese' },
|
||||
{ id: 'choam', faction: 'choam' }, { id: 'tl', faction: 'tleilaxu' },
|
||||
{ id: 'observer', faction: 'atreides' },
|
||||
] : [
|
||||
{ id: 'ix', faction: 'ixians' }, { id: 'holder', faction: 'atreides' },
|
||||
{ id: 'observer', faction: 'tleilaxu' },
|
||||
];
|
||||
const expansions = mixed ? ['ix', 'choam'] : ['ix'];
|
||||
return {
|
||||
status: 'playing', phase: 3, turn: 2, advanced, sequence: 0,
|
||||
expansions, ecazTreachery: true, players,
|
||||
cards: { version: 1, deck: NEXUS_FACTIONS.filter(card => card !== 'ixians' && card !== 'guild'),
|
||||
discard: [], hands: Object.fromEntries(players.map(player =>
|
||||
[player.id, player.id === 'holder' ? 'ixians' : player.id === 'observer' ? 'guild' : null])) },
|
||||
physicalCards: [...treacheryDeck(expansions), ...ecazTreacheryCards(), ...(mixed ? richeseCards() : [])],
|
||||
};
|
||||
}
|
||||
function authority(technology = false): IxianBetrayalAuthority {
|
||||
return { kind: technology ? 'technology' : 'bidding', provider: 'ix',
|
||||
nativeWindow: technology ? 'ixTechnology' : 'ixAuction',
|
||||
nativeContext: technology ? 'original-native-unused-card-exchange' : 'original-undrawn-native-pool',
|
||||
nativeRequired: ['observer'], nativePassed: ['observer'] };
|
||||
}
|
||||
function selection(ctx: IxianBetrayalContext): IxianBetrayalPhysicalSelection {
|
||||
return { expansions: ctx.expansions, ecazTreachery: ctx.ecazTreachery,
|
||||
richese: ctx.players.some(player => player.faction === 'richese') };
|
||||
}
|
||||
|
||||
for (const mixed of [false, true]) for (const advanced of [false, true]) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} selected ${mixed ? 'Ix/CHOAM with native cache' : 'Ix'} independent inventory cancels the original native Bidding attempt`, () => {
|
||||
const ctx = fixture(mixed, advanced), before = structuredClone(ctx), native = authority();
|
||||
assert.equal(ctx.physicalCards.length, mixed ? 60 : 50);
|
||||
const source = createIxianBetrayalSource(ctx, native, 'current-native-bidding-parent');
|
||||
assert.deepEqual(source.required, ['holder', 'observer']);
|
||||
assert.equal(source.eligible, 'holder');
|
||||
assert.deepEqual(ixianBetrayalResponders(ctx.cards, ctx.players, selection(ctx)), source.required);
|
||||
assert.equal(ixianBetrayalEligible(ctx.cards, ctx.players, 'holder', selection(ctx)), true);
|
||||
validateIxianBetrayalFrame(ctx, source, native, source.parent, source.required, ['observer']);
|
||||
const closed = closeIxianBetrayalSource(source, 'use', 'holder', initialIxianBetrayalCursor());
|
||||
assert.equal(closed.receipt.outcome, 'use', 'The receipt cancels this native attempt, not a different draw or exchange.');
|
||||
assert.equal(closed.receipt.source.nativeWindow, 'ixAuction');
|
||||
assert.equal(closed.cursor.sequence, 1);
|
||||
validateIxianBetrayalHistory([closed.receipt], closed.cursor, ctx.physicalCards, selection(ctx));
|
||||
assert.deepEqual(ctx, before, 'The pure native cancellation does not deal, discard or move physical inventory.');
|
||||
|
||||
// Historical cancellation survives ordinary later custody changes; the
|
||||
// caller still supplies the same public complete selected inventory.
|
||||
const recycled = structuredClone(ctx);
|
||||
recycled.cards.hands.holder = null;
|
||||
recycled.cards.discard.push('ixians');
|
||||
recycled.physicalCards = [...recycled.physicalCards].reverse();
|
||||
validateIxianBetrayalHistory([closed.receipt], closed.cursor, recycled.physicalCards, selection(recycled));
|
||||
assert.throws(() => validateIxianBetrayalFrame({ ...ctx, sequence: 1 }, source,
|
||||
native, source.parent, source.required, []), IxianBetrayalError);
|
||||
});
|
||||
}
|
||||
|
||||
void test('a passed native draw and a later canceled Technology exchange retain distinct original selected-deck sources', () => {
|
||||
const ctx = fixture(), bidding = authority();
|
||||
const first = createIxianBetrayalSource(ctx, bidding, 'original-draw-parent');
|
||||
const passed = closeIxianBetrayalSource(first, 'pass', null, initialIxianBetrayalCursor());
|
||||
assert.equal(passed.receipt.outcome, 'pass');
|
||||
const technology = authority(true), next = { ...ctx, sequence: passed.cursor.sequence };
|
||||
const second = createIxianBetrayalSource(next, technology, 'original-technology-parent');
|
||||
const used = closeIxianBetrayalSource(second, 'use', 'holder', passed.cursor);
|
||||
assert.notEqual(second.event, first.event);
|
||||
assert.equal(used.receipt.source.nativeWindow, 'ixTechnology');
|
||||
validateIxianBetrayalHistory([passed.receipt, used.receipt], used.cursor, ctx.physicalCards, selection(ctx));
|
||||
assert.throws(() => createIxianBetrayalSource({ ...next, advanced: false }, technology,
|
||||
second.parent), IxianBetrayalError, 'Basic does not gain Advanced Technology.');
|
||||
assert.throws(() => validateIxianBetrayalFrame(next, second, bidding, first.parent,
|
||||
second.required, []), IxianBetrayalError, 'The canceled exchange cannot authorize a different native draw.');
|
||||
});
|
||||
|
||||
void test('selected physical inventories require all three independent faces and only the actually seated native cache', () => {
|
||||
const ctx = fixture(), before = structuredClone(ctx);
|
||||
const independentIndex = ctx.physicalCards.findIndex(card => card.id === 'ecaz-recruits');
|
||||
assert.ok(independentIndex >= 0);
|
||||
const missing = ctx.physicalCards.filter((_, index) => index !== independentIndex);
|
||||
const duplicated = [...ctx.physicalCards, ctx.physicalCards[independentIndex]];
|
||||
const malformed = [...structuredClone(ctx.physicalCards)];
|
||||
malformed[independentIndex] = { ...malformed[independentIndex], name: 'Invented Recruits' };
|
||||
const missingCache = ctx.physicalCards.filter(card => !richeseCards().some(cache => cache.id === card.id));
|
||||
for (const physicalCards of [missing, duplicated, malformed, missingCache])
|
||||
assert.throws(() => createIxianBetrayalSource({ ...ctx, physicalCards }, authority(),
|
||||
'original-parent'), IxianBetrayalError);
|
||||
const ixOnly = fixture(false);
|
||||
assert.throws(() => createIxianBetrayalSource({ ...ixOnly,
|
||||
physicalCards: [...ixOnly.physicalCards, ...richeseCards()] }, authority(), 'original-parent'), IxianBetrayalError);
|
||||
assert.throws(() => createIxianBetrayalSource({ ...ctx, ecazTreachery: false }, authority(),
|
||||
'original-parent'), IxianBetrayalError, 'A physical hand cannot silently opt into the independent variant.');
|
||||
assert.deepEqual(ctx, before);
|
||||
});
|
||||
|
||||
void test('public selected families authorize native responders without admitting unselected or E3 seats', () => {
|
||||
const ctx = fixture();
|
||||
for (const expansions of [['choam'], ['ix', 'ix'], ['ix', 'ecaz'], []])
|
||||
assert.throws(() => createIxianBetrayalSource({ ...ctx, expansions }, authority(),
|
||||
'original-parent'), IxianBetrayalError);
|
||||
assert.throws(() => ixianBetrayalResponders(ctx.cards, ctx.players), IxianBetrayalError,
|
||||
'Omitted selection keeps the original base/Ix/Tleilaxu roster contract.');
|
||||
for (const faction of ['ecaz', 'moritani'] as const) {
|
||||
const players = ctx.players.map(player => player.id === 'holder' ? { ...player, faction } : player);
|
||||
assert.throws(() => ixianBetrayalResponders(ctx.cards, players, selection(ctx)), IxianBetrayalError);
|
||||
}
|
||||
const allied = { ...ctx, players: ctx.players.map(player => player.id === 'holder'
|
||||
? { ...player, ally: 'observer' } : player.id === 'observer' ? { ...player, ally: 'holder' } : player) };
|
||||
assert.deepEqual(ixianBetrayalResponders(allied.cards, allied.players, selection(allied)), []);
|
||||
assert.equal(ixianBetrayalEligible(allied.cards, allied.players, 'holder', selection(allied)), false);
|
||||
});
|
||||
|
||||
void test('omitted selection preserves original Ix47 source and receipt grammar and rejects silent new card inventory', () => {
|
||||
const selected = fixture(false), ctx = { ...selected, physicalCards: treacheryDeck(['ix']) };
|
||||
delete ctx.expansions;
|
||||
delete ctx.ecazTreachery;
|
||||
const native = authority(), source = createIxianBetrayalSource(ctx, native, 'legacy-parent');
|
||||
const explicit = createIxianBetrayalSource({ ...ctx, expansions: ['ix'], ecazTreachery: false },
|
||||
native, 'legacy-parent');
|
||||
assert.deepEqual(explicit, source, 'Public configuration adds no unconditional receipt fields or signature changes.');
|
||||
const closed = closeIxianBetrayalSource(source, 'use', 'holder', initialIxianBetrayalCursor());
|
||||
validateIxianBetrayalHistory([closed.receipt], closed.cursor, ctx.physicalCards);
|
||||
assert.throws(() => validateIxianBetrayalHistory([closed.receipt], closed.cursor,
|
||||
selected.physicalCards), IxianBetrayalError);
|
||||
validateIxianBetrayalHistory([closed.receipt], closed.cursor, selected.physicalCards, selection(selected));
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { applyAction, viewGame } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { MOBILE_LOCATION } from '../game/board';
|
||||
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
|
||||
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
|
||||
import {
|
||||
activateSelectedIndependentSuboids, actSelectedIndependentNexusBattle,
|
||||
advanceSelectedIndependentNexusBattle, createSelectedIndependentNexusBattleFixture,
|
||||
revealSelectedIndependentNexusPlans, selectedIndependentNexusClean,
|
||||
selectedIndependentNexusPlayer as player,
|
||||
} from './fixture-selected-independent-nexus-battles';
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} original selected Ix Cunning pays its mixed physical fee before Harass returns eight Suboids`, () => {
|
||||
const f = createSelectedIndependentNexusBattleFixture({ advanced, program: 'mixed-return', expansions: ['ix'] });
|
||||
assert.deepEqual(f.fee, { normal: 1, elite: 2 });
|
||||
assert.equal(f.ownShipment.action.amount, 9);
|
||||
assert.equal(f.ownShipment.action.elite, 0);
|
||||
assert.equal(player(f.beforeShipment, 'ixians').spice - player(f.afterShipment, 'ixians').spice, 9);
|
||||
assert.equal(f.purchases.length, 2);
|
||||
assert.ok(f.purchases.every(purchase => purchase.step.action.type === 'bid' && purchase.step.action.amount === 1));
|
||||
if (!advanced) assert.ok(f.actions.some(step => step.actor === 'ixians' && step.action.type === 'charity'),
|
||||
'After spending the original ten spice, Basic funds the second bid through its actual eligible charity claim');
|
||||
assert.ok(player(f.purchases[1].before, 'ixians').spice >= 1);
|
||||
assert.equal(f.afterNexusDraw.nexusCards!.cards!.hands.ixians, 'ixians');
|
||||
assert.equal(player(f.afterNexusDraw, 'guild').ally, 'emperor');
|
||||
assert.equal(player(f.afterNexusDraw, 'ixians').ally, null);
|
||||
assert.ok(player(f.game, 'ixians').hand.some(card => card.id === REINFORCEMENTS_CARD));
|
||||
assert.ok(player(f.game, 'ixians').hand.some(card => card.id === HARASS_WITHDRAW_CARD));
|
||||
const before = player(f.game, 'ixians');
|
||||
let game = activateSelectedIndependentSuboids(f.game, f.actions);
|
||||
const own = viewGame(game, 'ixians').battle!.ownForces!;
|
||||
assert.deepEqual([own.normal, own.elite, own.normalFixedHalf, own.normalFreeSupport], [9, 0, false, true]);
|
||||
assert.deepEqual(viewGame(game, 'ixians').battle!.reinforcements!.normal, 1);
|
||||
assert.deepEqual(viewGame(game, 'ixians').battle!.reinforcements!.elite, 2);
|
||||
game = revealSelectedIndependentNexusPlans(game, f.plans, f.actions);
|
||||
assert.equal(game.decision, null,
|
||||
'One ordinary pool has a unique undialed return and needs no invented allocation prompt');
|
||||
game = advanceSelectedIndependentNexusBattle(game, state => !state.battle && selectedIndependentNexusClean(state), f.actions);
|
||||
const after = player(game, 'ixians');
|
||||
assert.equal(game.lastBattleContext!.winner, 'ixians');
|
||||
assert.equal(game.lastBattleContext!.result, 'normal');
|
||||
assert.equal(after.forces[f.location] ?? 0, 0);
|
||||
assert.equal(after.reserves, before.reserves - 3 + 8);
|
||||
assert.equal(after.elites!.reserves, before.elites!.reserves - 2,
|
||||
'Returned ordinary Suboids cannot replace the originally quoted two Cyborg fee counters');
|
||||
assert.equal(after.tanks, before.tanks + 4, 'The original fee3 and one committed physical Suboid pay once');
|
||||
assert.equal(after.elites!.tanks, before.elites!.tanks + 2);
|
||||
assert.equal(after.forces[MOBILE_LOCATION], before.forces[MOBILE_LOCATION]);
|
||||
assert.equal(after.elites!.forces[MOBILE_LOCATION], before.elites!.forces[MOBILE_LOCATION]);
|
||||
assert.ok(!game.pendingIxSubstitution,
|
||||
'Reserve-fee Cyborgs are not actual battlefield Cyborg casualties available for substitution');
|
||||
assert.equal(player(game, 'guild').tanks, player(f.game, 'guild').tanks + 3);
|
||||
for (const card of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD])
|
||||
assert.equal(game.discard.filter(physical => physical.id === card).length, 1);
|
||||
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'ixians').length, 1);
|
||||
assert.equal(viewGame(game, 'ixians').nexusSuboids!.active, true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const modules of [false, true]) {
|
||||
void test(`original selected ${modules ? 'union E1/E2 Worlds/all14/phase' : 'E1'} Cunning preserves actual Cyborg losses and equal residual Suboid substitution`, () => {
|
||||
const f = createSelectedIndependentNexusBattleFixture({ advanced: true, program: 'substitution',
|
||||
expansions: modules ? ['ix', 'choam'] : ['ix'], homeworlds: modules, skills: modules,
|
||||
tech: modules, strongholds: modules });
|
||||
assert.deepEqual(f.fee, { normal: 3, elite: 0 });
|
||||
assert.equal(f.ownShipment.action.amount, 6);
|
||||
assert.equal(f.ownShipment.action.elite, 3);
|
||||
if (modules) {
|
||||
assert.ok(f.plans[0].action.reinforcementsSources);
|
||||
assert.equal(f.afterFirstMentat.strongholdCards!.owners.arrakeen, 'ixians');
|
||||
}
|
||||
const original = player(f.game, 'ixians');
|
||||
let game = activateSelectedIndependentSuboids(f.game, f.actions);
|
||||
game = revealSelectedIndependentNexusPlans(game, f.plans, f.actions);
|
||||
game = advanceSelectedIndependentNexusBattle(game, state => !state.response && !state.phaseOpening &&
|
||||
(state.decision?.kind === 'battleLosses' || state.decision?.kind === 'ixSubstitution'), f.actions);
|
||||
if (game.decision?.kind === 'battleLosses') {
|
||||
const choice = game.decision.options.findIndex(loss => loss.normal === 1 && loss.elite === 1); assert.ok(choice >= 0);
|
||||
game = actSelectedIndependentNexusBattle(game, { actor: 'ixians', action: { type: 'decision', choice } }, f.actions);
|
||||
}
|
||||
game = advanceSelectedIndependentNexusBattle(game, state => !state.response && !state.phaseOpening &&
|
||||
state.decision?.kind === 'ixSubstitution', f.actions);
|
||||
assert.equal(player(game, 'ixians').forces[f.location], 4);
|
||||
assert.equal(player(game, 'ixians').elites!.forces[f.location], 2);
|
||||
assert.equal(player(game, 'ixians').tanks, original.tanks + 5);
|
||||
assert.equal(player(game, 'ixians').elites!.tanks, original.elites!.tanks + 1);
|
||||
assert.deepEqual(game.pendingIxSubstitution!.losses, { [f.location]: 1 });
|
||||
assert.throws(() => applyAction(game, 'ixians', { type: 'decision',
|
||||
sources: { [f.location]: 2 }, recover: { [f.location]: 2 } }));
|
||||
game = actSelectedIndependentNexusBattle(game, { actor: 'ixians', action: { type: 'decision',
|
||||
sources: { [f.location]: 1 }, recover: { [f.location]: 1 } } }, f.actions);
|
||||
game = advanceSelectedIndependentNexusBattle(game, state => !state.battle && selectedIndependentNexusClean(state), f.actions);
|
||||
const after = player(game, 'ixians');
|
||||
assert.equal(game.lastBattleContext!.winner, 'ixians');
|
||||
assert.equal(after.forces[f.location], 4);
|
||||
assert.equal(after.elites!.forces[f.location], 3);
|
||||
assert.equal(after.tanks, original.tanks + 5);
|
||||
assert.equal(after.elites!.tanks, original.elites!.tanks);
|
||||
assert.equal(after.reserves, original.reserves - 3);
|
||||
assert.equal(after.elites!.reserves, original.elites!.reserves);
|
||||
assert.equal(game.discard.filter(card => card.id === REINFORCEMENTS_CARD).length, 1);
|
||||
assert.equal(after.forces[MOBILE_LOCATION], original.forces[MOBILE_LOCATION]);
|
||||
assert.equal(after.elites!.forces[MOBILE_LOCATION], original.elites!.forces[MOBILE_LOCATION]);
|
||||
});
|
||||
}
|
||||
|
||||
void test('original selected independent Cunning completes actual reserve fees and residual substitution before a genuinely dealt Face Dance', () => {
|
||||
const f = createSelectedIndependentNexusBattleFixture({ advanced: true, program: 'faceDance', expansions: ['ix', 'choam'] });
|
||||
assert.ok(player(f.afterSetup, 'tleilaxu').faceDancers!.some(dancer => dancer.leader === f.leader && !dancer.revealed));
|
||||
let game = revealSelectedIndependentNexusPlans(activateSelectedIndependentSuboids(f.game, f.actions), f.plans, f.actions);
|
||||
game = advanceSelectedIndependentNexusBattle(game, state => !state.response && !state.phaseOpening &&
|
||||
(state.decision?.kind === 'battleLosses' || state.decision?.kind === 'ixSubstitution'), f.actions);
|
||||
if (game.decision?.kind === 'battleLosses') {
|
||||
const choice = game.decision.options.findIndex(loss => loss.normal === 1 && loss.elite === 1); assert.ok(choice >= 0);
|
||||
game = actSelectedIndependentNexusBattle(game, { actor: 'ixians', action: { type: 'decision', choice } }, f.actions);
|
||||
}
|
||||
game = advanceSelectedIndependentNexusBattle(game, state => !state.response && !state.phaseOpening &&
|
||||
state.decision?.kind === 'ixSubstitution', f.actions);
|
||||
game = actSelectedIndependentNexusBattle(game, { actor: 'ixians', action: { type: 'decision',
|
||||
sources: { [f.location]: 1 }, recover: { [f.location]: 1 } } }, f.actions);
|
||||
game = advanceSelectedIndependentNexusBattle(game, state => !state.response && !state.phaseOpening &&
|
||||
state.decision?.kind === 'faceDance', f.actions);
|
||||
assert.equal(game.decision!.kind, 'faceDance');
|
||||
const before = player(game, 'ixians'), native = player(game, 'tleilaxu');
|
||||
assert.equal(before.forces[f.location], 4);
|
||||
assert.equal(before.elites!.forces[f.location], 3);
|
||||
assert.equal(game.discard.filter(card => card.id === REINFORCEMENTS_CARD).length, 1);
|
||||
game = actSelectedIndependentNexusBattle(game, { actor: 'tleilaxu', action: {
|
||||
type: 'decision', reveal: true, sources: { reserves: 4 }, sector: f.sector,
|
||||
} }, f.actions);
|
||||
game = advanceSelectedIndependentNexusBattle(game, state => !state.battle && selectedIndependentNexusClean(state), f.actions);
|
||||
const after = player(game, 'ixians');
|
||||
assert.equal(after.forces[f.location] ?? 0, 0);
|
||||
assert.equal(after.elites!.forces[f.location] ?? 0, 0);
|
||||
assert.equal(after.reserves, before.reserves + 4);
|
||||
assert.equal(after.elites!.reserves, before.elites!.reserves + 3);
|
||||
assert.equal(after.tanks, before.tanks);
|
||||
assert.equal(after.elites!.tanks, before.elites!.tanks);
|
||||
assert.equal(after.leaders.find(leader => leader.id === f.leader)!.dead, true);
|
||||
assert.equal(player(game, 'tleilaxu').reserves, native.reserves - 4);
|
||||
assert.equal(player(game, 'tleilaxu').forces[f.location], 4);
|
||||
assert.equal(player(game, 'tleilaxu').faceDancers!.find(dancer => dancer.leader === f.leader)!.revealed, true);
|
||||
});
|
||||
|
||||
void test('all four minimal policies retain a positive native Reinforcements path after genuine selected-family Suboid Cunning', () => {
|
||||
const f = createSelectedIndependentNexusBattleFixture({ advanced: true, program: 'substitution', expansions: ['ix', 'choam'], homeworlds: true });
|
||||
const game = activateSelectedIndependentSuboids(f.game, f.actions);
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const view = viewGame(game, 'ixians');
|
||||
view.players.find(player => player.id === 'ixians')!.bot = difficulty;
|
||||
const action = botActions(view).find(action => action.type === 'battlePlan' && Number(action.dial) > 0 &&
|
||||
(action.weapon === REINFORCEMENTS_CARD || action.defense === REINFORCEMENTS_CARD));
|
||||
assert.ok(action, `${difficulty} must expose a positive original card plan`);
|
||||
assert.ok(action.reinforcementsSources, `${difficulty} must retain the original native source quote`);
|
||||
assert.ok(applyAction(game, 'ixians', action).battle!.plans.ixians);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { ecazTreacheryCards } from '../game/ecaz-cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||
import { NEXUS_FACTIONS } from '../game/nexus-cards';
|
||||
import { RECRUITS_CARD_ID } from '../game/recruits';
|
||||
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
|
||||
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
|
||||
import { createIndependentCardModulesFixture, type IndependentCardModulesOptions } from './fixture-independent-card-modules';
|
||||
import {
|
||||
actSelectedIndependentNexus, advanceSelectedIndependentNexus, createSelectedIndependentNexusBattleFixture,
|
||||
createSelectedIndependentNexusCacheFixture, createSelectedIndependentNexusCardsFixture,
|
||||
revealSelectedIndependentNexusPlans, selectedIndependentChoamFuelAction,
|
||||
selectedIndependentNexusClean as clean, selectedIndependentNexusPlayer as player,
|
||||
} from './fixture-selected-independent-nexus-cards';
|
||||
|
||||
/** Component quantities only; no historical-save or seat assurance programme. */
|
||||
function selectedStocks(game: Game): void {
|
||||
const actual = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
|
||||
...(game.ixSetupCards ?? []), ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
|
||||
...(game.auction?.cards.slice(game.auction.index + Number(!!game.currentAuctionSale)) ?? [])];
|
||||
const expected = [...treacheryDeck(game.expansions), ...ecazTreacheryCards(),
|
||||
...(game.players.some(player => player.faction === 'richese') ? richeseCards() : [])];
|
||||
assert.deepEqual(actual.map(card => card.id).sort(), expected.map(card => card.id).sort());
|
||||
const cards = game.nexusCards!.cards!;
|
||||
assert.deepEqual([...cards.deck, ...cards.discard, ...Object.values(cards.hands).filter(card => card !== null)].sort(), [...NEXUS_FACTIONS].sort());
|
||||
if (game.leaderSkills) assert.deepEqual([...game.leaderSkills.deck,
|
||||
...Object.values(game.leaderSkills.offers).flatMap(offer => offer.cards),
|
||||
...game.leaderSkills.assignments.map(assignment => assignment.skill)].sort(), LEADER_SKILL_CARDS.map(card => card.id).sort());
|
||||
}
|
||||
|
||||
const configurations: Array<IndependentCardModulesOptions & { factions: NonNullable<IndependentCardModulesOptions['factions']> }> = [
|
||||
{ expansions: ['ix'], factions: ['atreides', 'guild'], skills: true, tech: false },
|
||||
{ expansions: ['choam'], factions: ['harkonnen', 'emperor', 'guild'], homeworlds: true, tech: true },
|
||||
{ expansions: ['ix', 'choam'], factions: ['richese', 'choam', 'guild', 'atreides'], advanced: true, skills: true, homeworlds: true, tech: true, strongholds: true },
|
||||
{ expansions: ['ix'], factions: ['ixians', 'tleilaxu', 'atreides', 'emperor', 'guild'], advanced: true, skills: true, homeworlds: true, tech: true, strongholds: true },
|
||||
{ expansions: ['ix', 'choam'], factions: ['ixians', 'tleilaxu', 'richese', 'choam', 'harkonnen', 'guild'], advanced: true, skills: true, homeworlds: true, tech: true, strongholds: true },
|
||||
];
|
||||
for (const configuration of configurations) void test(`fresh ${configuration.expansions!.join('+')} independent/Nexus ${configuration.factions.length}-seat native setup keeps selected components and original reaction stamps`, () => {
|
||||
const fixture = createIndependentCardModulesFixture({ ...configuration, independent: true, nexus: true });
|
||||
const game = fixture.game;
|
||||
assert.deepEqual(game.expansions, configuration.expansions);
|
||||
assert.equal(game.ecazTreachery, true);
|
||||
assert.equal(game.status, 'playing');
|
||||
assert.equal(game.nexusCards!.cards!.deck.length, 12);
|
||||
assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card => card === null));
|
||||
assert.equal(game.nexusCards!.phase, null);
|
||||
assert.equal(!!game.nexusIxianReplacementPreview, !configuration.factions.includes('ixians'));
|
||||
assert.equal(!!game.nexusIxianBetrayalPreview, configuration.factions.includes('ixians'));
|
||||
assert.equal(!!game.nexusHarkonnenBetrayalPreview, configuration.factions.includes('harkonnen'));
|
||||
assert.equal(!!game.guildBetrayalPreview, configuration.factions.includes('guild'));
|
||||
if (configuration.homeworlds) assert.ok(game.homeworlds!.custody);
|
||||
if (configuration.skills) assert.equal(game.leaderSkills!.assignments.length, configuration.factions.length);
|
||||
if (configuration.strongholds) assert.ok(Object.values(game.strongholdCards!.owners).every(owner => owner === null));
|
||||
selectedStocks(game);
|
||||
});
|
||||
|
||||
void test('native CHOAM Cunning consumes a genuinely auctioned independent Recruits as Kulon fuel without playing Recruits', () => {
|
||||
const fixture = createSelectedIndependentNexusCardsFixture({ advanced: true, skills: true, tech: true, strongholds: true });
|
||||
assert.ok(player(fixture.afterPurchases, 'choam').hand.some(card => card.id === RECRUITS_CARD_ID));
|
||||
assert.ok(fixture.actions.some(step => step.actor === 'choam' && step.action.type === 'bid' && step.action.amount === 1));
|
||||
assert.equal(fixture.draws.find(draw => draw.step.actor === 'choam')!.after.nexusCards!.cards!.hands.choam, 'choam');
|
||||
let game = advanceSelectedIndependentNexus(fixture.game, state => state.phase === 5 && state.active === 'choam' && clean(state));
|
||||
assert.equal(game.recruits ?? null, null);
|
||||
const action = selectedIndependentChoamFuelAction(game), spice = player(game, 'choam').spice;
|
||||
game = actSelectedIndependentNexus(game, 'choam', action);
|
||||
assert.equal(game.nexusCards!.cards!.hands.choam, null);
|
||||
if (game.response) {
|
||||
assert.equal(game.response.kind, 'choamWorthless');
|
||||
assert.ok(player(game, 'choam').hand.some(card => card.id === RECRUITS_CARD_ID));
|
||||
} else assert.equal(game.nexusChoamLast!.stage, 'complete',
|
||||
'Without an eligible native counter, automatic completion needs no fabricated response');
|
||||
game = advanceSelectedIndependentNexus(game, clean);
|
||||
assert.equal(game.nexusChoamLast!.stage, 'complete');
|
||||
assert.equal(game.choamMovement!.bonus, 1);
|
||||
assert.equal(game.recruits ?? null, null, 'CHOAM conversion never runs the independent card’s printed Revival effect');
|
||||
assert.equal(game.discard.filter(card => card.id === RECRUITS_CARD_ID).length, 1);
|
||||
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'choam').length, 1);
|
||||
assert.equal(player(game, 'choam').spice, spice);
|
||||
assert.throws(() => applyAction(game, 'choam', action));
|
||||
selectedStocks(game);
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced all14/phase' : 'Basic'} native Richese two-marker Cunning materializes before independent Reinforcements pays its physical fee`, () => {
|
||||
const fixture = createSelectedIndependentNexusBattleFixture({ advanced, skills: advanced, tech: advanced, strongholds: advanced });
|
||||
assert.equal(player(fixture.afterFirstReveal, 'richese').reserves, 20);
|
||||
assert.equal(player(fixture.afterPair, 'richese').reserves, 15);
|
||||
assert.equal(player(fixture.afterPair, 'richese').forces[fixture.location], 5);
|
||||
assert.ok(player(fixture.afterPair, 'richese').noField!.deployed);
|
||||
assert.equal(fixture.afterPair.nexusCards!.cards!.hands.richese, null);
|
||||
assert.equal(fixture.afterPair.nexusRicheseCunningLast!.stage, 'shipped');
|
||||
const shipment = fixture.afterPair.nexusGuildBetrayalHistory!.at(-1)!;
|
||||
assert.equal(shipment.invoice.source, 'reserve');
|
||||
assert.equal(shipment.invoice.shipper, 'richese');
|
||||
assert.equal(shipment.invoice.turn, 2);
|
||||
assert.equal(shipment.invoice.price, 1);
|
||||
assert.equal(shipment.invoice.originalReceiver, 'guild');
|
||||
assert.equal(shipment.recipient, null);
|
||||
assert.ok(fixture.actions.some(step => step.actor === 'choam' && step.action.type === 'guildBetrayalPass' && step.action.event === shipment.invoice.event),
|
||||
'Cunning settles its genuine signed Guild fee acknowledgement before force custody changes');
|
||||
const preview = viewGame(fixture.game, 'richese').battle!.reinforcements!;
|
||||
assert.equal(preview.blocked, null);
|
||||
assert.deepEqual(preview.noField, { materializes: 3, reservesAfterReveal: 12 });
|
||||
assert.equal(preview.normal, 3);
|
||||
let game = revealSelectedIndependentNexusPlans(fixture.game, fixture.plans);
|
||||
assert.equal(player(game, 'richese').noField!.deployed, null);
|
||||
assert.equal(player(game, 'richese').forces[fixture.location], 8);
|
||||
assert.equal(player(game, 'richese').reserves, 12);
|
||||
const tanks = player(game, 'richese').tanks;
|
||||
game = advanceSelectedIndependentNexus(game, state => !state.battle && clean(state));
|
||||
assert.equal(game.lastBattleContext!.winner, 'richese');
|
||||
assert.equal(game.lastBattleContext!.result, 'normal');
|
||||
assert.equal(player(game, 'richese').reserves, 9, 'Three genuine uncommitted reserves pay the card after its own concealed army appears');
|
||||
assert.equal(player(game, 'richese').tanks, tanks + 5, 'Three reserve-fee counters plus two actual dial casualties');
|
||||
assert.equal(player(game, 'richese').forces[fixture.location], 6);
|
||||
assert.equal(game.discard.filter(card => card.id === REINFORCEMENTS_CARD).length, 1);
|
||||
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'richese').length, 1);
|
||||
selectedStocks(game);
|
||||
});
|
||||
|
||||
void test('an actual paid ten-force shipment leaves too few reserves for Reinforcements after the retained Cunning pair, while the plain plan remains legal', () => {
|
||||
const fixture = createSelectedIndependentNexusBattleFixture({ drainReserves: true });
|
||||
assert.equal(player(fixture.afterFirstReveal, 'richese').reserves, 20);
|
||||
assert.deepEqual(player(fixture.afterFirstReveal, 'richese').noField!.tokens.map(token => token.value).sort((a, b) => a - b), [0, 3, 5]);
|
||||
const funding = fixture.reserveFunding, shipment = fixture.reserveShipment;
|
||||
assert.ok(funding && shipment);
|
||||
assert.equal(funding.step.action.type, 'bribe');
|
||||
assert.equal(player(funding.after, 'choam').spice, player(funding.before, 'choam').spice - 9);
|
||||
assert.equal(player(funding.after, 'richese').bribes, player(funding.before, 'richese').bribes + 9);
|
||||
assert.equal(shipment.step.action.type, 'ship');
|
||||
assert.equal(player(shipment.after, 'richese').spice, player(shipment.before, 'richese').spice - 10);
|
||||
assert.equal(player(shipment.after, 'richese').reserves, 10);
|
||||
assert.equal(player(shipment.after, 'richese').forces['habbanya_ridge_sietch:17'], 10);
|
||||
assert.equal(shipment.after.nexusCards!.cards!.hands.richese, 'richese');
|
||||
assert.equal(fixture.beforePair.turn, 3);
|
||||
assert.equal(player(fixture.afterPair, 'richese').reserves, 5);
|
||||
const preview = viewGame(fixture.game, 'richese').battle!.reinforcements!;
|
||||
assert.deepEqual(preview.noField, { materializes: 3, reservesAfterReveal: 2 });
|
||||
assert.match(preview.blocked!, /three.*reserves.*after materialization/i);
|
||||
assert.throws(() => applyAction(fixture.game, 'richese', fixture.plans[0].action), /at least three own forces in reserves/i);
|
||||
assert.ok(player(fixture.game, 'richese').hand.some(card => card.id === REINFORCEMENTS_CARD));
|
||||
assert.deepEqual(fixture.game.battle!.plans, {});
|
||||
const plans = fixture.plans.map(plan => plan.actor === 'richese' ? { ...plan, action: { ...plan.action, defense: null } } : plan);
|
||||
let game = revealSelectedIndependentNexusPlans(fixture.game, plans);
|
||||
assert.equal(player(game, 'richese').reserves, 2);
|
||||
assert.equal(player(game, 'richese').forces[fixture.location], 8);
|
||||
game = advanceSelectedIndependentNexus(game, state => !state.battle && clean(state));
|
||||
assert.equal(game.lastBattleContext!.winner, 'richese');
|
||||
assert.ok(player(game, 'richese').hand.some(card => card.id === REINFORCEMENTS_CARD));
|
||||
assert.equal(game.discard.filter(card => card.id === REINFORCEMENTS_CARD).length, 0);
|
||||
selectedStocks(game);
|
||||
});
|
||||
|
||||
void test('actual Richese cache purchase opens absent-Ix replacement and draws an original independent Harass without charging twice', () => {
|
||||
const fixture = createSelectedIndependentNexusCacheFixture({ advanced: true, skills: true, tech: true, strongholds: true });
|
||||
assert.equal(fixture.game.nexusCards!.cards!.hands.choam, 'ixians');
|
||||
assert.equal(fixture.game.players.some(player => player.faction === 'ixians'), false);
|
||||
assert.equal(player(fixture.game, 'choam').spice, player(fixture.beforePurchase, 'choam').spice - fixture.price);
|
||||
assert.ok(player(fixture.game, 'choam').hand.some(card => card.id === fixture.card.id));
|
||||
const paid = player(fixture.game, 'choam').spice, seller = player(fixture.game, 'richese').spice;
|
||||
let game = actSelectedIndependentNexus(fixture.game, 'choam', fixture.useAction);
|
||||
game = advanceSelectedIndependentNexus(game, state => !state.pendingNexusIxianReplacement && !state.currentAuctionSale && clean(state));
|
||||
assert.equal(player(game, 'choam').spice, paid);
|
||||
assert.equal(player(game, 'richese').spice, seller + fixture.price);
|
||||
assert.ok(player(game, 'choam').hand.some(card => card.id === HARASS_WITHDRAW_CARD));
|
||||
assert.equal(fixture.replacement.id, HARASS_WITHDRAW_CARD);
|
||||
assert.equal(player(game, 'choam').hand.some(card => card.id === fixture.card.id), false);
|
||||
assert.equal(game.discard.filter(card => card.id === fixture.card.id).length, 1);
|
||||
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'ixians').length, 1);
|
||||
assert.equal(game.nexusCards!.cards!.hands.choam, null);
|
||||
assert.equal(game.richeseCache!.some(card => card.id === fixture.card.id), false);
|
||||
selectedStocks(game);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { selectedIndependentNexusProfile, nexusCoreProfile } from '../game/faction-module-profile';
|
||||
import { createTechTokens } from '../game/tech-tokens';
|
||||
import { createSelectedIndependentEntry, type SelectedIndependentEntryOptions } from './fixture-selected-independent-nexus-entry';
|
||||
|
||||
const entries: SelectedIndependentEntryOptions[] = [
|
||||
{expansions:['ix'], factions:['ixians','tleilaxu','atreides']},
|
||||
{expansions:['choam'], factions:['richese','choam','guild'], advanced:true, skills:true, homeworlds:true, tech:true, strongholds:true},
|
||||
{expansions:['ix','choam'], factions:['ixians','richese','harkonnen','atreides'], advanced:true, skills:true, homeworlds:true, tech:true, strongholds:true},
|
||||
{expansions:['ix','choam'], factions:['atreides','guild']},
|
||||
{expansions:['choam'], factions:['emperor','atreides'], advanced:true, homeworlds:true, strongholds:true},
|
||||
{expansions:['ix'], factions:['tleilaxu','guild'], homeworlds:true},
|
||||
];
|
||||
for (const options of entries) void test(`normal independent Nexus entry ${options.expansions.join('+')}/${options.factions.join('+')} retains selected components`, () => {
|
||||
const {lobby, setup, game} = createSelectedIndependentEntry(options);
|
||||
assert.equal(lobby.lobbyOptions?.ecazTreachery, true);
|
||||
assert.equal(lobby.nexusCards?.cards, null);
|
||||
assert.equal(game.ecazTreachery, true);
|
||||
assert.deepEqual(game.expansions, options.expansions);
|
||||
assert.equal(nexusCoreProfile(game), true);
|
||||
assert.equal(selectedIndependentNexusProfile(game), true);
|
||||
assert.equal(!!game.leaderSkills, !!options.skills);
|
||||
assert.equal(!!game.homeworlds, !!options.homeworlds);
|
||||
assert.equal(!!game.techTokens, !!options.tech);
|
||||
assert.equal(!!game.strongholdCards, !!options.strongholds);
|
||||
assert.equal(game.phase, 0);
|
||||
assert.equal(game.nexusCards!.cards!.deck.length, 12);
|
||||
assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card => card === null));
|
||||
const ordinary = game.deck.length + game.discard.length + game.players.reduce((sum, player) => sum + player.hand.length, 0);
|
||||
assert.equal(ordinary, options.expansions.includes('ix') ? 50 : 38);
|
||||
assert.equal(game.richeseCache?.length ?? 0, options.factions.includes('richese') ? 10 : 0);
|
||||
assert.equal(!!game.nexusIxianReplacementPreview, !options.factions.includes('ixians'));
|
||||
assert.equal(!!game.nexusIxianBetrayalPreview, options.factions.includes('ixians'));
|
||||
assert.equal(!!game.nexusHarkonnenBetrayalPreview, options.factions.includes('harkonnen'));
|
||||
assert.equal(!!game.guildBetrayalPreview, options.factions.includes('guild'));
|
||||
if (options.skills) assert.ok(['skillTreachery','leaderSkills'].includes(setup.setupStage!),
|
||||
'The original Ixian starting-card choice may precede skill training');
|
||||
if (setup.techTokens) {
|
||||
assert.ok(Object.values(lobby.techTokens!).every(token => token.owner === null));
|
||||
assert.deepEqual(setup.techTokens, createTechTokens(setup.players),
|
||||
'Original setup assigns each seated native its printed Tech token; missing natives wait for Storm');
|
||||
}
|
||||
if (game.strongholdCards) assert.ok(Object.values(game.strongholdCards.owners).every(owner => owner === null));
|
||||
});
|
||||
|
||||
void test('selected core capability requires real independent cards and selected registered seats', () => {
|
||||
const base = {advanced:true, expansions:['ix'], players:[{faction:'ixians'},{faction:'atreides'}], nexusCards:true, ecazTreachery:true};
|
||||
assert.equal(selectedIndependentNexusProfile(base), true);
|
||||
for (const invalid of [
|
||||
{...base, ecazTreachery:false}, {...base, expansions:['ix','ix']},
|
||||
{...base, players:[{faction:'ixians'},{faction:'richese'}]},
|
||||
{...base, players:[{faction:'ixians'},{faction:'ixians'}]},
|
||||
{...base, techTokens:true}, {...base, advanced:false, strongholdCards:true},
|
||||
{...base, discoveryEnabled:true}, {...base, semutaPreview:true},
|
||||
]) assert.equal(selectedIndependentNexusProfile(invalid), false);
|
||||
});
|
||||
Reference in new issue
Block a user