Connect native Nexus reactions to classic independent cards

This commit is contained in:
Quintine committed 2026-10-18 04:51:33 +11:00
1 parent c466107452
commit 366c967f8a
15 files changed
+483 -29

No files matched your search

+21
View File
@@ -29,6 +29,27 @@ optional skill bands and combinations remain open.
Rules implementation continues; comprehensive assurance and administration
remain deferred until all rules are implemented.
**Selected-card native reactions — 18 October. Prototyped / Partial.**
Real Harkonnen call/Nexus and paid Reinforcements/Ixian purchase exposed absent
original effect prompts. Fresh classic2–6 independent36/Nexus12 optional14 now
initializes both native effect stamps; Harkonnen only if seated. Played games
without stamps keep their behavior. Public selection binds canonical36 and
retains original base33/history grammar; other overlays remain separate.
Personal/allied native allowance or Karama, actual Harass cancellation/return,
one private real Mentat replacement and paid-card Use/Keep/bonus pass. Three
actual390px journeys reach original Mentat inspector or next lot; no errors/
overflow. Four legal policies execute eight choices/four real replacements.
Inherited neutral acknowledgement and replacement-before-bonus timing remain
visibly provisional, not universal/publisher rulings.
Frozen16-file **156/156**, focused6/6, final types/lint/build pass. Fixture
post-training randomness and trained-disc posture use native controls; an
inferred Map key needed only a type annotation. Temporary memory/routes
removed/released204; no stored room/reset/restart/deployment. Other modes,
universal timing and deferred assurance remain open.
[Exact source and original quantities](NEXUS_CARD_RULES.md#classic-selected-card-reactions--18-october-2026).
**Classic independent Nexus — 18 October. Prototyped / Partial.**
Original classic3 normal start rejected unchanged. One public configuration
capability now composes fresh classic2–6 Basic/Advanced independent36/Nexus12,
+34
View File
@@ -1,5 +1,39 @@
# Dune implementation status
## 18 October 2026 — Native classic independent-card Nexus reactions
Real Harkonnen call with an actually drawn Harkonnen Nexus resolved without
acknowledgement; actual paid Reinforcements purchase with Ixian Nexus exposed
no replacement. Fresh classic2–6 Basic/Advanced independent36/Nexus12 optional14
now initializes both original effect states, Harkonnen only if actually seated.
Public configuration accepts those intrinsic stamps without admitting other
overlays. Existing played games without stamps retain their behavior.
Harkonnen physical source recognizes the explicit independent selection and
retains original three-element historical inventories; selected36 adds one
true marker. Ixian canonical recognition admits the three original cards only
in the selected current context, retaining v1 receipt shape. The same neutral
acknowledgement/native-counter and replacement-before-bonus conventions remain
visibly provisional. No numeric rule or universal policy is added.
Six new consumer cases exercise personal Use/actual Mentat draw, all14 Pass/
Traitor-canceled Harass, allied printed counter and allowance, paid replacement
and original Harkonnen Keep/bonus. The original producer selects unused setup
and post-training lottery inputs and submits actual skilled-disc shield choices;
no played card/force/death/clock is assigned. Initial stochastic traitor offers
and an unhidden trained disc were fixture defects, corrected with native inputs.
Frozen16-file **156/156**, focused6/6, final types/lint and build pass. An
inferred literal-key Map needed an explicit string key; the type-only correction
passes final checks without changing runtime. Three actual390px journeys reach
private real Mentat Traitor inspection, paid2 replacement/next lot, or Basic
Harkonnen purse8/hand3→4 with purchase/Nexus retained and one bonus. No errors/
overflow. Four legal policies execute eight native choices and four real Mentat
draws; Ixian Pass is a legal retention decision, not a calibration result.
Temporary routes/memory removed/released204, no stored room/reset/restart/deploy.
Existing build warnings, wider modes, universal timing and assurance stay open.
[Canonical source and current boundary](NEXUS_CARD_RULES.md#classic-selected-card-reactions--18-october-2026).
## 18 October 2026 — Classic independent Treachery and Nexus
Original normal classic3 independent+Nexus start rejected unchanged. One public
+13
View File
@@ -7,6 +7,19 @@ and fixture opt-ins are removed; existing games are not retrofitted.
Earlier dated references to a separate entry describe historical checkpoints.
See [current activation and limits](SPICE_BANKER_RUNTIME.md#mandatory-ordinary-skills-default--17-october-2026).
## Classic selected-card native reactions — 18 October 2026
The fresh independent36/Nexus12 optional14 entry now initializes original
Ixian purchase replacement and actual native Harkonnen call cancellation,
without retrofitting played games. Native skilled-disc shield choices precede
real plans; public selection binds the physical catalogue/census. The same
provisional neutral acknowledgement and original replacement-before-bonus
protocols are inherited, not a new universal timing policy.
Actual allied native counter/Use→Harass→Mentat and paid-card Use/Keep controls,
six combined regressions and four legal policies pass. Other overlays, full
combinations, strategy and assurance remain separate.
[Source and exact current proof](NEXUS_CARD_RULES.md#classic-selected-card-reactions--18-october-2026).
## Classic independent Nexus composition — 18 October 2026
Fresh classic2–6 Basic/Advanced all14 may now select independent36 plus
+33
View File
@@ -2,6 +2,39 @@
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.
## Classic selected-card reactions — 18 October 2026
Frozen16-file **156/156**, focused new6/6, final types/lint and build pass.
Initial type feedback required an explicit string-keyed canonical map; its
type-only correction changes no runtime behavior. Build retains existing
module-register/large-chunk/route-classification warnings and no QA routes.
The independent36/Nexus12 entry below, optionally all14 Skills, now initializes
the existing Ixian purchased-card and actually seated Harkonnen declared-Traitor
effect stamps at fresh setup. Played games without those stamps keep their
existing behavior. Both protocols are reused, not a second reaction mechanism;
their original neutral acknowledgement and Harkonnen replacement-before-bonus
timing remain explicitly provisional. Other reaction overlays stay separate.
Real closing draws supply Harkonnen/Ixian faces. Genuine paid shipment and
original skilled-disc posture precede Harass plans and the actual native call.
Personal Use, all-pass, allied Karama preemption/allowance and one actual Mentat
replacement are connected. Original paid2 independent-card Use/Keep preserves
wallet/physical delivery and native Harkonnen's one bonus. Public selection
binds canonical independent identities; no hidden-hand inference or new rules.
New `tests/classic-independent-nexus-reactions.test.ts` has six consumer cases.
Its named producer uses original unused setup/post-training lotteries,
alliance/draw, shipment, paid auction and battle actions, not staged played
state. Three390px journeys reach actual private replacement inspection or the
next original lot, with no errors/overflow. Four legal policies exercise
eight native choices and four real Mentat replacements; no strategy claim.
Temporary routes/memory are removed/released204, with no stored room, reset,
server restart or deployment. Full modes, wider combinations, universal timing
and deferred assurance remain open.
[Harkonnen source/census](NEXUS_HARKONNEN_RULES.md#classic-independent-card-native-reactions--18-october-2026)
· [Ixian selected-card authority](NEXUS_IXIAN_REPLACEMENT_RULES.md#classic-independent-card-native-reactions--18-october-2026).
## Classic independent Treachery composition — 18 October 2026
**Prototyped / Partial.** Publisher E3 pp4/9/11 permits the three-card
+41
View File
@@ -2,6 +2,47 @@
Source audit, 10 September 2026. This is a rules contract, not a runtime completion claim. The [common Nexus lifecycle](NEXUS_CARD_RULES.md) and existing expansion release gates still apply. The pending reaction-privacy question remains unresolved; this audit does not authorize a Betrayal reaction policy.
## Classic independent-card native reactions — 18 October 2026
**Prototyped / Partial.** Fresh normal classic2–6 Basic/Advanced
independent36/Nexus12, optionally all14 Skills, now initializes the existing
Harkonnen effect state when Harkonnen is actually seated. Ixian replacement
initializes alongside it. These are native fresh capability stamps, not
another selected module or a played-save retrofit. Native selected families,
Worlds, phase modules, Discovery/Semuta and other reaction envelopes remain
separate. Original standalone base33 admission is unchanged.
The original printed effect below and independent-variant authority compose
through the same declared-Traitor protocol. Under standing choose-recommended
authority, inherit the existing visibly provisional native-counter-first /
public-possible-holder acknowledgement convention for this bounded entry.
This is not a new publisher ruling or an answer to the universal policy
outside it. Personal calls gain no new native counter; allied calls retain
their actual printed Karama allowance first.
The current physical census uses the public independent-card selection, never
a held face or inferred deck length. Its historical inventory adds a true
selection marker only for independent36; original three-element inventories
and signatures retain their grammar. Card identities/names/effects and the
actual classic Traitor universe remain exact. Starting training and the real
closing Nexus draw precede the battle; no force, hand, death or clock is staged.
Actual personal Use preserves Harass's one undialed return and the living
target, returns only the declared Traitor, then draws the real private reserve
top once at Mentat. All14 Pass retains the actual call: leader death, one
physical Tank loss and canceled Harass. A real allied printed counter prevents
the call before any Nexus cost; allowing it opens the same acknowledgement
with Guild as actual combatant beneficiary. Native skilled-disc shield choices
are made through their original controls.
Six combined consumer regressions pass in
`tests/classic-independent-nexus-reactions.test.ts`, backed by its original
action producer. The actual390px allied scene passes native allowance→owned
Nexus Use→Harass→original winner inspection→Collection→private Mentat draw,
including the new actual Traitor inspector. Four legal policies reach Mentat
with one replacement each. Broader source, assurance and deployed gates remain.
[Combined current evidence](NEXUS_CARD_RULES.md#classic-selected-card-reactions--18-october-2026).
## Permanent markers and legal replacement return — 15 October 2026
Shared Harkonnen exchange snapshots now include Moritani's derived permanent
+27
View File
@@ -2,6 +2,33 @@
Source audit: 2026-09-10. Bounded local prototype integration and verification: 2026-10-01. **Partial development verified**, not complete Ixian/Nexus family or expansion acceptance. This document records the source and runtime boundary of the fresh `ixian-replacement` profile. Existing [Nexus release gates](NEXUS_CARD_RUNTIME.md) remain in force.
## Classic independent-card native reactions — 18 October 2026
Fresh normal classic2–6 Basic/Advanced independent36/Nexus12 now initializes
the original purchased-card effect, optionally all14 Skills, alongside native
Harkonnen cancellation where that faction is seated. No played game receives
a new capability stamp. Original standalone classic/Tleilaxu/Richese entries
and other roster/module boundaries remain; no separate overlay is selected.
The canonical catalogue recognizes the three original independent faces, but
current sources admit them only from the explicit public selection. Other
families/cache still require their original selected native context. Historical
v1 receipts keep their existing shape. This extends physical recognition and
fresh admission, not printed effect, pricing or universal response timing.
Actual paid2 delivers Reinforcements before the original private buyer choice.
Use spends one Ixian Nexus, discards only that just-purchased card and draws
the actual unused deck top, with the same hand size/wallet and one lot advance.
Pass keeps purchase/Nexus and lets native Harkonnen receive one bonus card:
actual Basic purse8, hand3→4 and next lot1. Original replacement-before-bonus
order and neutral wrong-face acknowledgement remain visibly provisional.
Six combined consumer regressions pass. Two actual390px purchase journeys
exercise all14 Use and native Harkonnen Keep, including original action notices
and physical hand inspectors; no errors/overflow. Four legal policies retain
this valuable independent lot with Pass and the original paid wallet.
[Combined source-bound proof and remaining gates](NEXUS_CARD_RULES.md#classic-selected-card-reactions--18-october-2026).
## Direct Richese special purchase during Bidding — 16 October 2026
An actual Advanced Richese special-Karama cache acquisition during Bidding
+1
View File
@@ -703,6 +703,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
| Reinforcements / clean selected Ix and CHOAM | Admit original independent cards with clean selected native/base Basic/Advanced rosters and no optional modules/overlays. Reuse existing provisional normal-first/all-outcome payment, original No-Field materialization before the fee, then successful Harass return; physical Stone ignores +2. Native cyborg substitution uses its own casualty origins, not a duplicate reserve fee. This closes admission consistency, not a new numerical ruling or public expansion gate. | [Profile and bounded proof](REINFORCEMENTS_RUNTIME.md#clean-selected-ixchoam-families--16-october-2026) |
| Independent Treachery / all14 Skills | Printed variant independence composes registered actual selected-family all14 Basic/Advanced2–6, including mixed E3/E1/E2, unused decks, original Worlds and Tech3+/Advanced Strongholds2+. No Nexus/exact E3 family/card dependency is invented. Original36/38/50, separate cache and native setup/custody remain. Bare mixed E3 World/phase cards, original Nexus/Discovery/Semuta, Advanced role, shared Duke and effect-specific limits stay separate. Fees/H/committed-only Suk, own-World bans and separate Banker/Tech remain. No new numeric rule/save retrofit. | [Current registered native scope](LEADER_SKILLS_RUNTIME.md#registered-native-e3-selected-decks--18-october-2026), [physical sources](ECAZ_TREACHERY_RULES.md) |
| Classic independent Treachery / Nexus | Printed independent-variant authority composes fresh classic2–6 Basic/Advanced independent36/Nexus12, optionally all14, through one public configuration predicate and original setup. Recruits changes ordinary rates/total7, not fixed Tleilaxu group price/free classification. Cunning keeps temporary ordinary counters, separate Reinforcements reserve fees, actual Harass returns and original cancellation. No new numeric rule or played-save conversion. Native selected families, World/phase/Discovery/Semuta, reaction-preview and other effect-specific bounds remain separate; inherited provisional timings are not promoted to publisher rulings. | [Source and exact native proof](NEXUS_CARD_RULES.md#classic-independent-treachery-composition--18-october-2026) |
| Classic independent native Nexus reactions | Fresh independent36/Nexus12 optional14 initializes the existing Ixian purchase and actual Harkonnen declared-Traitor effect stamps; played games remain unchanged. Public selection binds canonical faces and the original census, with a new independent marker only where selected. Under standing choose-recommended authority, inherit the existing bounded native-counter-first/neutral acknowledgement and replacement-before-Harkonnen-bonus conventions, visibly provisional, not new publisher or universal rulings. Personal/allied source, real Mentat replacement and original paid/card/force mechanics remain. Other native/module/reaction envelopes stay separate. | [Current source and actual controls](NEXUS_CARD_RULES.md#classic-selected-card-reactions--18-october-2026) |
| Fresh native Ecaz/Moritani defaults | Fresh actual Eca/Mori roles reuse registered selected-deck all14 capability, including mixed families and original World/phase/cards without Nexus. Printed Eca participation and Advanced Mori assassination initialize only for actual seats; Basic gains no Advanced power and unused decks create no faction seat. Bare rosters and original Nexus/Discovery/Semuta retain their envelopes; Advanced role/shared Duke guards stay. No played-save forfeiture/opportunity history is fabricated. | [Current registered source envelope](LEADER_SKILLS_RUNTIME.md#registered-native-e3-selected-decks--18-october-2026), [Mori authority](MORITANI_ASSASSINATE_LEADERS.md#connected-scope-and-private-information) |
| Independent Treachery / original phase modules | Printed independence retains original Tech3–6 Basic/Advanced and/or Strongholds2–6 Advanced with classic/E1/E2 and original standalone/paired E3 bare cards; all14 also composes registered mixed selected families and Worlds. Real first-Storm/end-Mentat owners, separate delayed income and physical funding remain. Fees/H are not revival/shipment and +2 is not supported force. Native coalition/assassination and original queue stay source-labelled. Bare mixed E3 World/phase, original Nexus/Discovery/Semuta and role/effect guards remain. | [Original phase contract](ECAZ_TREACHERY_RULES.md#original-phase-module-composition--16-october-2026), [registered scope](LEADER_SKILLS_RUNTIME.md#registered-native-e3-selected-decks--18-october-2026) |
| Classic World independent cards / phase modules | Compose original unused Tech3+ and/or Advanced Strongholds2+ with the existing classic36-card Homeworld entry. Preserve native typed custody, current separate-request rates/paid ledgers, exact source-bearing R fees, original face/support, fee/dial disjointness and fee-before-Harass. Keep every own-World Harass ban; native returns are not shipment/free Revival. Original delayed income and earned cards add no personal subsidy or supported +2. This inherits existing provisional timing, not a new publisher ruling or native expansion/public gate. | [Source-bound contract](ECAZ_TREACHERY_RULES.md#classic-world-phase-module-composition--16-october-2026) |
+27 -4
View File
@@ -9648,7 +9648,7 @@ export function initializeNexusGameForAudit(state: Game): Game {
}
/** Fresh audit admission only; ordinary games and public starts never opt in. */
export function initializeHarkonnenNexusBetrayalGameForAudit(state: Game): Game {
requireRule(harkonnenNexusBetrayalModeSupported(state) &&
requireRule(!state.ecazTreachery && !state.leaderSkills && harkonnenNexusBetrayalModeSupported(state) &&
state.nexusCards?.cards === null && state.nexusCards.phase === null &&
state.nexusHarkonnenBetrayalPreview === undefined &&
state.pendingNexusHarkonnenBetrayal === undefined &&
@@ -9668,6 +9668,7 @@ export function initializeHarkonnenNexusBetrayalGameForAudit(state: Game): Game
return g;
}
function harkonnenNexusBetrayalModeSupported(g: Game): boolean {
if (classicIndependentNexusProfile(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 &&
@@ -9684,7 +9685,7 @@ function harkonnenNexusBetrayalContext(g: Game) {
leader:b.plans[id].leader,...(b.plans[id].kwisatz === undefined ? {} : {kwisatz:b.plans[id].kwisatz}),
}]));
const battle = {event:b.event!,attacker:b.attacker,defender:b.defender,plans,heroLeaderIds:[]};
return {status:g.status,phase:g.phase,turn:g.turn,advanced:g.advanced,
return {status:g.status,phase:g.phase,turn:g.turn,advanced:g.advanced,ecazTreachery:g.ecazTreachery === true,
sequence:g.nexusHarkonnenBetrayalCursor!.sequence,players:g.players,
cards:g.nexusCards!.cards!,physicalCards:physicalTreacheryCards(g),
traitors:nexusTraitorSnapshot(g),universe:nexusTraitorUniverse(g),battle};
@@ -10012,7 +10013,7 @@ function decideIxianNexusBetrayal(g: Game,id: string,action: Action): void {
}
/** Fresh classic, Tleilaxu or Richese profile; no public start or retrofit. */
export function initializeIxianNexusReplacementGameForAudit(state: Game): Game {
requireRule(!state.nexusIxianReplacementPreview && !state.pendingNexusIxianReplacement &&
requireRule(!state.ecazTreachery && !state.leaderSkills && !state.nexusIxianReplacementPreview && !state.pendingNexusIxianReplacement &&
!state.pendingNexusIxianSpecialPurchase &&
state.nexusIxianReplacementHistory === undefined && state.nexusIxianReplacementCursor === undefined &&
ixianNexusReplacementModeSupported(state),
@@ -10027,6 +10028,7 @@ export function initializeIxianNexusReplacementGameForAudit(state: Game): Game {
return g;
}
function ixianNexusReplacementModeSupported(g: Game): boolean {
if (classicIndependentNexusProfile(g)) return true;
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)) ||
@@ -10057,7 +10059,7 @@ function ixianNexusReplacementContext(g: Game) {
requireRule(g.pendingNexusIxianSpecialPurchase || g.auction || g.richeseAuction,
'The Nexus purchased-card choice lost its original purchase.');
return {status:g.status,phase:g.phase,turn:g.turn,sequence:g.nexusIxianReplacementCursor!.sequence,
advanced:g.advanced,expansions:g.expansions,richese:!!byFaction(g,'richese'),
advanced:g.advanced,expansions:g.expansions,richese:!!byFaction(g,'richese'),ecazTreachery:g.ecazTreachery === true,
auction:g.auction,richeseAuction:g.richeseAuction ?? null,sale:g.currentAuctionSale ?? null,
specialPurchase:g.pendingNexusIxianSpecialPurchase ?? null,
players:g.players.map(p => ({id:p.id,hand:p.hand,handLimit:handLimit(p)})),
@@ -10894,6 +10896,27 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
g.spiceBankerIncomePreview = true;
g.spiceBankerIncome = bankerIncomeRule(() => createBankerIncomeState(bankerIncomeContext(g)));
}
if (classicIndependentNexusProfile(g)) {
requireRule(g.nexusIxianReplacementPreview === undefined &&
g.nexusIxianReplacementHistory === undefined && g.nexusIxianReplacementCursor === undefined &&
!g.pendingNexusIxianReplacement && !g.pendingNexusIxianSpecialPurchase &&
g.nexusHarkonnenBetrayalPreview === undefined && g.nexusHarkonnenBetrayalHistory === undefined &&
g.nexusHarkonnenBetrayalCursor === undefined && !g.pendingNexusHarkonnenBetrayal &&
!g.pendingNexusHarkonnenReplacement && g.nexusHarkonnenReplacementHistory === undefined,
'Native Nexus reactions initialize only with fresh independent-card setup.');
g.nexusIxianReplacementPreview = true;
g.pendingNexusIxianReplacement = null;
g.nexusIxianReplacementHistory = [];
g.nexusIxianReplacementCursor = initialIxianReplacementCursor();
if (byFaction(g, 'harkonnen')) {
g.nexusHarkonnenBetrayalPreview = true;
g.pendingNexusHarkonnenBetrayal = null;
g.nexusHarkonnenBetrayalHistory = [];
g.nexusHarkonnenBetrayalCursor = initialHarkonnenBetrayalCursor();
g.pendingNexusHarkonnenReplacement = null;
g.nexusHarkonnenReplacementHistory = [];
}
}
initializeSetup(g, strongholdFactions || !!g.strongholdCards);
if (nativeTech && byFaction(g, 'ecaz')) g.ecazOccupyPreview = true;
return normalizeAutomaticGame(g);
+3 -3
View File
@@ -27,7 +27,7 @@ export type FactionModuleProfile = {
};
/** Independent36 + original Nexus12 in classic tables, optionally all14 Skills.
* Initializers own undealt component freshness; other overlays stay separate. */
* Fresh native Harkonnen/Ixian effect stamps are not additional selected modules. */
export function classicIndependentNexusProfile(
game: Partial<FactionModuleProfile> & Pick<FactionModuleProfile, 'expansions'> & {
sandtrout?: unknown; moritaniAssassinatePreview?: unknown;
@@ -39,8 +39,8 @@ export function classicIndependentNexusProfile(
game.homeworlds || game.techTokens || game.strongholdCards || game.sandtrout ||
game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker ||
game.semutaPreview || game.moritaniAssassinatePreview || game.advancedPreview || game.kullPreview || game.nexusKullPreview ||
game.guildBetrayalPreview || game.richeseBetrayalPreview || game.nexusIxianReplacementPreview ||
game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview) return false;
game.guildBetrayalPreview || game.richeseBetrayalPreview ||
game.nexusIxianBetrayalPreview) 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;
+20 -12
View File
@@ -1,4 +1,5 @@
import { baseDeck, leaders, type Card } from './cards';
import { ecazTreacheryCards } from './ecaz-cards';
import {
nexusCardMode, validateNexusCards, validateNexusPlayers,
type NexusPlayer, type NexusState,
@@ -24,6 +25,8 @@ export type HarkonnenBetrayalContext = {
turn: number;
advanced: boolean;
sequence: number;
/** Public selection; absence retains the original base33 census. */
ecazTreachery?: boolean;
players: readonly NexusPlayer[];
cards: NexusState;
physicalCards: readonly Card[];
@@ -80,6 +83,7 @@ const classic: Readonly<Record<string, true>> = {
atreides: true, harkonnen: true, emperor: true, fremen: true, guild: true, beneGesserit: true,
};
const canonicalCards = new Map(baseDeck().map(card => [card.id, card]));
const independentCards = new Map<string, Card>(ecazTreacheryCards().map(card => [card.id, card]));
const declarationKeys = ['event', 'voter', 'beneficiary', 'target', 'leader', 'identity', 'signature'] as const;
const plain = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value) &&
@@ -117,15 +121,16 @@ function validateUniverse(universe: readonly string[], players: readonly NexusPl
universe.every(identity => expected.includes(identity)),
'Harkonnen Betrayal needs the original complete canonical classic Traitor universe.');
}
function validatePhysical(cards: readonly Card[]): void {
requireBetrayal(Array.isArray(cards) && cards.length === canonicalCards.size &&
new Set(cards.map(card => card?.id)).size === canonicalCards.size && cards.every(card => {
function validatePhysical(cards: readonly Card[], independent = false): void {
const count = canonicalCards.size + (independent ? independentCards.size : 0);
requireBetrayal(Array.isArray(cards) && cards.length === count &&
new Set(cards.map(card => card?.id)).size === count && cards.every(card => {
if (!plain(card) || !text(card.id)) return false;
const expected = canonicalCards.get(card.id);
const expected = canonicalCards.get(card.id) ?? (independent ? independentCards.get(card.id) : undefined);
return !!expected && exact(card, ['id', 'name', 'kind',
...(expected.effect === undefined ? [] : ['effect'])]) && card.name === expected.name &&
card.kind === expected.kind && card.effect === expected.effect;
}), 'Harkonnen Betrayal requires complete unique canonical base33 Treachery custody.');
}), 'Harkonnen Betrayal requires complete unique canonical selected Treachery custody.');
}
function validateBattle(battle: Battle): void {
requireBetrayal(exact(battle, ['event', 'attacker', 'defender', 'plans', 'heroLeaderIds']) &&
@@ -196,11 +201,12 @@ export function harkonnenBetrayalEvent(
}
function validateContext(ctx: HarkonnenBetrayalContext): void {
requireBetrayal(plain(ctx) && ctx.status === 'playing' && ctx.phase === 6 &&
whole(ctx.turn) && ctx.turn > 0 && whole(ctx.sequence) && typeof ctx.advanced === 'boolean',
whole(ctx.turn) && ctx.turn > 0 && whole(ctx.sequence) && typeof ctx.advanced === 'boolean' &&
(ctx.ecazTreachery === undefined || typeof ctx.ecazTreachery === 'boolean'),
'Harkonnen Betrayal requires its current Battle phase and independent cursor.');
validateRoster(ctx.players);
validateNexusCards(ctx.cards, ctx.players);
validatePhysical(ctx.physicalCards);
validatePhysical(ctx.physicalCards, ctx.ecazTreachery);
validateUniverse(ctx.universe, ctx.players);
validateNexusTraitorSnapshot(ctx.traitors, ctx.universe);
requireBetrayal(ctx.traitors.players.length === ctx.players.length &&
@@ -214,7 +220,9 @@ function sourceAuthority(source: HarkonnenBetrayalSource): HarkonnenBetrayalAuth
nativeContext: source.nativeContext, nativeRequired: source.nativeRequired, nativePassed: source.nativePassed };
}
function inventory(ctx: HarkonnenBetrayalContext): string {
return JSON.stringify([ctx.cards, ctx.traitors, ctx.physicalCards]);
const census: unknown[] = [ctx.cards, ctx.traitors, ctx.physicalCards];
if (ctx.ecazTreachery) census.push(true);
return JSON.stringify(census);
}
function sourceSignature(source: HarkonnenBetrayalSource): string {
return JSON.stringify(['harkonnenBetrayalSource', source.version, source.event, source.turn,
@@ -247,13 +255,13 @@ function validateSavedSource(value: unknown): asserts value is HarkonnenBetrayal
} catch {
throw new HarkonnenBetrayalError('Harkonnen Betrayal lost its original physical census.');
}
requireBetrayal(Array.isArray(stored) && stored.length === 3,
requireBetrayal(Array.isArray(stored) && (stored.length === 3 || (stored.length === 4 && stored[3] === true)),
'Harkonnen Betrayal lost its original physical census.');
type SavedInventory = [NexusState, NexusTraitorSnapshot, readonly Card[]];
const [cards, traitors, physicalCards] = stored as SavedInventory;
type SavedInventory = [NexusState, NexusTraitorSnapshot, readonly Card[], true?];
const [cards, traitors, physicalCards, independent] = stored as SavedInventory;
validateNexusCards(cards, source.players);
validateNexusTraitorSnapshot(traitors, source.universe);
validatePhysical(physicalCards);
validatePhysical(physicalCards, independent);
requireBetrayal(traitors.players.length === source.players.length &&
source.players.every(player => traitors.players.some(seat =>
seat.id === player.id && seat.faction === player.faction)) &&
+6 -1
View File
@@ -1,4 +1,5 @@
import { baseDeck, choamDeck, ixDeck, type Card } from './cards';
import { ecazTreacheryCards } from './ecaz-cards';
import { richeseCards } from './richese-cards';
import type { RicheseAuction } from './richese-auction';
@@ -19,6 +20,8 @@ export type IxianReplacementContext = {
turn: number;
sequence: number;
expansions: readonly string[];
/** Original independent three-card selection, never inferred from a hand. */
ecazTreachery?: boolean;
richese: boolean;
/** Native Black Market bidding and Richese special Karama require Advanced play. */
advanced: boolean;
@@ -77,7 +80,8 @@ const base = catalogue(baseDeck());
const ix = catalogue(ixDeck());
const choam = catalogue(choamDeck());
const cache = catalogue(richeseCards());
const canonical: Readonly<Record<string, Card>> = { ...base, ...ix, ...choam, ...cache };
const independent = catalogue(ecazTreacheryCards());
const canonical: Readonly<Record<string, Card>> = { ...base, ...ix, ...choam, ...cache, ...independent };
const genesis = 'ixian-replacement:0';
const plain = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value) &&
@@ -104,6 +108,7 @@ function canonicalCard(value: unknown): value is Card {
function selectedCard(value: unknown, ctx: IxianReplacementContext): value is Card {
return canonicalCard(value) && (
Object.hasOwn(base, value.id) ||
(ctx.ecazTreachery === true && Object.hasOwn(independent, value.id)) ||
(Object.hasOwn(ix, value.id) && ctx.expansions.includes('ix')) ||
(Object.hasOwn(choam, value.id) && ctx.expansions.includes('choam')) ||
(Object.hasOwn(cache, value.id) && ctx.expansions.includes('choam') && ctx.richese)
+9 -8
View File
@@ -4574,7 +4574,7 @@ export const RULE_TOPICS: RuleTopic[] = [
summary: 'An unallied holder with Ixians absent may spend Ixian Nexus to replace the exact card just bought in a normal auction or supported native Richese sale, preserving original settlement and drawing the actual deck top privately.',
searchText: 'Ixian Nexus Secret Ally purchased card replacement normal auction cache Black Market Karama full hand empty deck same card',
steps: [
'The fresh local profile admits two-to-six unique seats in Basic/Advanced: classics/base33, Tleilaxu plus classics/Ix47, or Richese plus classics/CHOAM35 and original cache, with Ixians absent and Nexus alone. Normal purchases qualify, as do genuine positive Richese cache sales in Basic/Advanced and Black Market sales in Advanced. Sandtrout is omitted only for fresh native Ix setup. Other natives/modules and public starts or played-save conversion remain excluded.',
'Original standalone entries admit classic/base33, Tleilaxu plus classics/Ix47, or Richese plus classics/CHOAM35 and cache, with Ixians absent and Nexus alone. Fresh normal classic2–6 Basic/Advanced independent36/Nexus12 now also initializes this same effect, optionally all14 Skills. Native selected families, Worlds, phase/Discovery/Semuta and other reaction envelopes retain their separate bounds; no played game gains a new stamp.',
'Nexus has no starting deal. A draw requires a qualifying closing Nexus with an alliance. A two-seat profile can continue its base game but cannot naturally create an unallied Secret Ally holder; actual replacement needs at least three seats, or four for two simultaneous unallied recipients.',
'The photographed publisher face grants an independent Secret Ally operation when Ixians are absent. Remain unallied and hold the actual singleton Ixian Nexus. Native Ixian allied replacement is a different cancelable advantage and is not removed or used as a fallback. The adopted unofficial Advanced rulebook supplies relevant core rules, not this absent Nexus face or a new Nexus ruling.',
'Normal paid/Karama purchases, genuine native Richese sold lots and actual Advanced special-Karama acquisition during Bidding supply their own purchase authority. A direct purchase has already spent its Karama, three spice and once-use; its target/price3 are not an unrelated row or sale. Use/Keep resumes original income, active player and suspended controls. Only unpaid bids remain reserved; already-delivered sale commitments are not held twice. Other phases keep native acquisition without a Bidding-only Nexus choice. Unbid keeps, gifts, bonus draws, Technology swaps and old hand cards supply no purchase.',
@@ -4586,7 +4586,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Harkonnen uses a visibly provisional order matching the native Ixian ally sale pipeline: this replacement choice, original income response, original bonus-card response, then the next lot. It is product composition, not a printed priority ruling. Use draws the replacement before any allowed bonus; Keep leaves that top card for the bonus. Replacement grants no second bonus or slot at the eight-card limit. Canceling the later native bonus never undoes the replacement or original payment.',
'Keep the original payment, funded contribution and free/paid source. There is no refund or second debit. Original income and the native auction continuation finish once after the choice, preserving existing response and decision priority. Other lot, card, transfer and payment actions cannot bypass the pending source lock; independent own-seat autopilot changes remain available.',
'Refresh restores the same pending event or closed outcome without another payment, draw, shuffle or Nexus spend. A later genuine purchase may create a new event; closed, stale, foreign or malformed events cannot reopen an old one. Later transfers and reshuffles do not invalidate history merely because the old physical card has moved again. Receipt links are nonrecursive so saved history stays linear across retained purchases.',
'Easy, Medium, Hard and Brutal use their own projected legal Use/Pass choices. These are minimal legal policies, not calibrated Nexus strategy. Frozen native, private controls, actual local CLI/phone and authenticated recovery evidence verifies only this bounded path. Full faction/module games, wider compositions, public starts and deployed acceptance remain separate gates.',
'Easy, Medium, Hard and Brutal use their own projected legal Use/Pass choices, not calibrated strategy. Current classic independent-card controls preserve original paid settlement and native Harkonnen bonus through this same protocol. Earlier native, private and saved evidence remains dated. Full faction/module games, wider compositions and deployed acceptance remain separate gates.',
],
example: 'You buy a card for four spice and inspect it privately. Pass keeps it and the Nexus. Use keeps the original four-spice purchase settled, spends Ixian Nexus, discards that exact card and draws the next deck card. If the draw deck is empty, its new discard joins the actual recycled pile and can be drawn again.',
related: ['nexus-cards', 'bidding', 'ix-technology', 'card-karama', 'alliance-funding', 'privacy', 'implementation-checklist'],
@@ -4595,7 +4595,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Player controls', status: 'Partial', detail: 'Actual390px native special purchase controls are enabled at three remaining spice during older paid income. Direct Use preserves the older card and both credits; reserved-bid Keep reaches a distinct genuine normal offer, then one row advance. Guidance states original cost/once-use committed and no new auction. Provisional cache/Harkonnen scope remains visible. No overflow/browser/application error; broader acceptance remains open.', evidence: ['components/nexus-ixian-replacement.tsx', 'components/richese-special-karama.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Four minimal legal policies use their own projected offer while original settlement waits. Sixteen original-state actions cover quiet Emperor/bank, reserved bid and nested paid income direct purchases; previous source-qualified paths remain in the contract. No strategy or difficulty calibration is claimed.', evidence: ['game/bots.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Actual auction/direct authority, native price3 and cost commitment, once-use, active/control restoration, paid-versus-unpaid reservations, source-v1 preservation and provisional cache/Harkonnen interpretations are documented. Outside Bidding the native special purchase adds no Nexus offer. Other modules and universal/publisher priority remain separate.', evidence: ['docs/NEXUS_IXIAN_REPLACEMENT_RULES.md', 'docs/NEXUS_CARD_RULES.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Ten new direct scenarios and affected eleven-file107/107 pass, including existing funding consumers, with types/lint/build. Ten original runtime paths, sixteen legal policy actions and two390px actual native-action journeys prove original cost/target, Emperor/bank, quiet controls, reserved-bid automatic payment, distinct following normal offer, nested income and real Revival exclusion. A paid-bid double reservation was fixed; an obsolete serialized-identifier substring pin was deleted while semantic rival privacy checks remain. No broad, HTTP, saved/deployed assurance; all five facets remain Partial.', evidence: ['tests/nexus-ixian-replacement.test.ts', 'tests/nexus-ixian-replacement-engine.test.ts', 'tests/fixtures/nexus-ixian-native-depleted.json', 'tests/fixture-nexus-ixian-replacement.ts', 'tests/fixture-native-ixian-replacement.ts', 'tests/native-ixian-replacement.test.ts', 'tests/bg-ixian-replacement.test.ts', 'tests/fixture-bg-ixian-replacement.ts', 'tests/harkonnen-ixian-replacement.test.ts', 'tests/fixture-harkonnen-ixian-replacement.ts', 'tests/richese-ixian-replacement.test.ts', 'tests/fixture-richese-ixian-replacement.ts', 'tests/richese-ixian-special-replacement.test.ts', 'tests/fixture-richese-ixian-special-replacement.ts', 'tests/richese-ixian-direct-replacement.test.ts', 'tests/fixture-richese-ixian-direct-replacement.ts'] },
{area:'Verification',status:'Partial',detail:"Classic independent36/Nexus12 optional14 now initializes the original effect at fresh setup. Actual paid2 delivers selected Reinforcements; Use discards only that card for the real deck top with no second invoice, while Harkonnen Pass keeps both cards and allows exactly one original bonus. Six combined consumer regressions, three390px journeys and four legal policies pass. Older native/direct and saved evidence remains dated; broader modules and deployed assurance stay open.",evidence:['tests/classic-independent-nexus-reactions.test.ts','tests/fixture-classic-independent-nexus-reactions.ts','tests/nexus-ixian-replacement.test.ts', 'tests/nexus-ixian-replacement-engine.test.ts', 'tests/fixtures/nexus-ixian-native-depleted.json', 'tests/fixture-nexus-ixian-replacement.ts', 'tests/fixture-native-ixian-replacement.ts', 'tests/native-ixian-replacement.test.ts', 'tests/bg-ixian-replacement.test.ts', 'tests/fixture-bg-ixian-replacement.ts', 'tests/harkonnen-ixian-replacement.test.ts', 'tests/fixture-harkonnen-ixian-replacement.ts', 'tests/richese-ixian-replacement.test.ts', 'tests/fixture-richese-ixian-replacement.ts', 'tests/richese-ixian-special-replacement.test.ts', 'tests/fixture-richese-ixian-special-replacement.ts', 'tests/richese-ixian-direct-replacement.test.ts', 'tests/fixture-richese-ixian-direct-replacement.ts']},
],
},
{
@@ -4640,15 +4640,16 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Advanced & expansions',
coverage: 'Partial',
developmentStage: 'Verified',
summary: 'The fresh local harkonnen-betrayal preview cancels one original native Harkonnen traitor call, returns that physical card immediately and gives Harkonnen a private replacement during Mentat Pause.',
summary: 'Fresh supported classic starts cancel one original native Harkonnen traitor call, return that physical card immediately and give Harkonnen a private replacement during Mentat Pause.',
searchText: 'Harkonnen Nexus Betrayal revealed declared traitor ally caller beneficiary shuffle replacement Mentat neutral acknowledgement',
steps: [
'The bounded development profile requires native Harkonnen and two to six unique classic factions, Basic or Advanced, the base Treachery Deck and Nexus alone. No public start, existing-game retrofit, expansion deck, Homeworld, Leader Skill, Discovery or other overlay is enabled.',
'The original standalone preview requires native Harkonnen, classic2–6 Basic/Advanced, base33 and Nexus alone. Fresh normal classic independent36/Nexus12 now initializes the same effect, optionally all14 Skills, alongside native Ixian replacement. Original setup and public selection bind the physical census. No played game is reflagged; native selected families, Worlds, phase/Discovery/Semuta and other reaction envelopes stay separate.',
'Nexus has no initial deal. An actual alliance-qualified closing Nexus must supply the unallied holder. An effect scenario needs at least three seats; two seats can continue ordinary play without inventing a card. Two simultaneous receivers need four actual seats.',
'The photographed publisher face cancels a revealed Harkonnen traitor, shuffles that exact card back immediately, and gives Harkonnen one replacement during Mentat Pause. It is not a draw-now exchange, whole-battle ban or refund of an already resolved outcome. Native Cunning and absent-Harkonnen Secret Ally remain separate effects.',
'Only a genuine legally matching native personal or allied Harkonnen call is a source. Preserve the actual caller, combatant beneficiary, target, original revealed plans and physical identity. A false or invalid call, protected leader, another faction or finished battle cannot offer this effect.',
'Resolve the original allied Harkonnen Karama counter first, including legal Advanced Bene Gesserit conversion. Native cancellation opens no Nexus gate or cost. A personal Harkonnen call gains no invented native counter. After native allowance, declare the actual matched traitor before its outcome and await the neutral acknowledgements.',
'Every publicly possible unallied non-Harkonnen held-Nexus owner sees the same declared-traitor acknowledgement, regardless of secret face. Only an actual privately eligible Harkonnen-card holder sees Use and their own unavailable reason. Native-counter-first and this uniform local preview timing are bounded inferences, not an answer to the outstanding universal reaction-policy question.',
'The classic independent-card and optional Skills entry inherits that same visibly provisional acknowledgement convention. Original allied printed Karama may prevent the call before any Nexus opportunity; allowing it preserves the actual caller and combatant beneficiary. No second counter window or new universal policy is invented.',
'Pass retains both cards and cannot be withdrawn for that event; all required passes retain the original call and resume it once. Use spends the physical singleton Nexus, returns only that declared Harkonnen traitor to the actual shuffled reserve, prevents only that call and resumes the surviving original battle once.',
'The replacement is automatic at the actual current Mentat opening, before optional actions and victory acceptance in this bounded profile. Draw the actual reserve top privately once; it may be the returned card after its shuffle. No extra confirmation, selected replacement, Treachery slot, activation spice, force or leader cost is added.',
'The public declaration exposes only its actual matched traitor and roles, not other held Harkonnen cards, rival Nexus faces, reserve order or private source. Inspect that declared Traitor Card through its public inspector, and inspect only your own actually held Nexus. A replacement identity remains private to Harkonnen; prior public history must not claim continued ownership of the returned card.',
@@ -4662,7 +4663,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Player controls', status: 'Partial', detail: 'Public declared-Traitor inspector, own eligible event-only Use/Pass/Nexus inspector, sole/multiple public ownership and every-seat independent autopilot are connected. Actual phone Use, wrong-face Pass, partial refresh and raw administrator owner agree.', evidence: ['components/nexus-harkonnen-betrayal.tsx', 'components/game-table.tsx', 'game/table-turn.ts', 'db/admin-directory.ts'] },
{ area: 'AI', status: 'Partial', detail: 'All four minimal policies consume canonical own-view choices and native pending priority with legal paced continuation; full strategy and calibration remain separate.', evidence: ['game/bot-nexus-harkonnen-betrayal.ts', 'game/bots.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Printed immediate/delayed operations, personal/allied roles, inferred local counter/privacy convention, fresh natural card rights and unchanged outside-profile/module/public/deployment questions are explicit.', evidence: ['docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_CARD_RUNTIME.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Genuine native personal/allied Use/all-pass and actual Mentat top, printed/BG preemption, canonical stock, own/public entitlement, four bots and authenticated restart/races/rejection pass. Four privately backed-up original-setup CLI/phone rooms prove Use, wrong-face Pass, partial refresh, administrator owner and saved stock through real HTTP Mentat once. Independent native/privacy reviews have no finding; wider and deployed acceptance remain open.', evidence: ['tests/fixture-nexus-harkonnen-betrayal.ts', 'tests/nexus-harkonnen-betrayal.test.ts', 'tests/nexus-harkonnen-betrayal-runtime.test.ts', 'tests/nexus-harkonnen-betrayal-controls.test.tsx', 'tests/bot-nexus-harkonnen-betrayal.test.ts', 'tests/nexus-harkonnen-betrayal-recovery.test.ts', 'tests/prototype-room.test.ts', 'tests/admin-directory.test.ts'] },
{area:'Verification',status:'Partial',detail:"Classic independent36/Nexus12 optional14 now initializes the existing effect only at fresh setup. Genuine personal Use, all-pass and allied native counter or allowance preserve actual Harass, the declared physical Traitor and one private real Mentat draw. Six combined consumer regressions, three390px native journeys and four legal policies pass. Earlier native/saved reviews remain dated; universal timing, wider modules, assurance and deployed acceptance remain open.",evidence:['tests/classic-independent-nexus-reactions.test.ts','tests/fixture-classic-independent-nexus-reactions.ts','tests/fixture-nexus-harkonnen-betrayal.ts', 'tests/nexus-harkonnen-betrayal.test.ts', 'tests/nexus-harkonnen-betrayal-runtime.test.ts', 'tests/nexus-harkonnen-betrayal-controls.test.tsx', 'tests/bot-nexus-harkonnen-betrayal.test.ts', 'tests/nexus-harkonnen-betrayal-recovery.test.ts', 'tests/prototype-room.test.ts', 'tests/admin-directory.test.ts']},
],
},
{
@@ -4697,10 +4698,10 @@ export const RULE_TOPICS: RuleTopic[] = [
'With Harkonnen absent, Secret Ally draws two Traitor Cards during Mentat Pause, then you choose exactly two distinct held identities to return. Newly drawn cards may be returned. Tleilaxu receives Face Dancers and can return newly drawn or existing unrevealed dancers; previously revealed dancers stay protected.',
'The Harkonnen exchange spends the Nexus Card when you draw and shuffles the returned cards into the Traitor Deck after your selection. Your original hand size is restored. Other players see that the exchange is pending, without learning your drawn or returned identities.',
'Moritani victim cards permanently set aside are outside reserve, ordinary hands and Face Dancers, so Nexus draws and returns cannot recycle them. A card merely drawn as an earlier Moritani replacement is different: it may be legally returned and later drawn again, with the older receipt unchanged. Actual Hasimir6 return, resumed Mori placement and a later Esmar3 retirement/redraw are connected; two historical references still represent one physical card.',
'Harkonnen Betrayal has a separate fresh local harkonnen-betrayal profile for an actual personal or allied native traitor call. After any original allied counter allows it, uniform public possible held-Nexus owners acknowledge the actually declared traitor before its outcome. Use returns that exact physical traitor immediately and schedules one private actual Mentat replacement; all-pass keeps the original call. This provisional bounded convention does not answer the outstanding universal reaction policy; see its dedicated guide for bounded verified native/private/recovery/CLI/phone evidence and unchanged module/public/deployment gates.',
'Harkonnen Betrayal follows an actual personal or allied native traitor call in its original standalone preview or fresh classic independent36/Nexus12 entry, optionally all14 Skills. After original allied counters allow it, public possible held-Nexus owners acknowledge the actually declared traitor before its outcome. Use returns that physical card immediately and schedules one private actual Mentat replacement; all-pass preserves the call. This inherited provisional convention does not settle universal reaction timing outside these bounds.',
'Tleilaxu Cunning sets aside all your revealed Face Dancers, draws replacements secretly, then shuffles that temporary group into the Traitor Deck. Unrevealed dancers stay held; permanent Moritani victim markers never enter that draw/return group. Original native Mentat and all-three-revealed usage/order remain distinct. Recorded source zones and old absent-zone history remain unchanged by later live markers.',
'Ixian Cunning is used in your battle before submitting your Battle Plan. Your Suboids then count as strength one without spice support in every battle for the rest of the turn, in Basic and Advanced. Cyborg strength and support are unchanged. Earlier inspected or promised plan elements remain binding; you cannot activate the card if the new strength would make them impossible.',
'Ixian Secret Ally has a fresh local ixian-replacement profile for normal paid or printed Karama-paid auction purchases in classic base-deck/Nexus Basic or Advanced games. A supported unallied buyer may spend its actual Nexus to discard exactly the just-purchased card and draw the real deck top privately; Pass keeps both cards. Full-hand replacement and empty-deck same-card redraw are allowed, and original payment/income/auction continuation occur once. Harkonnen buyers, cache/Black Market/special origins and combined modules remain guarded. The neutral buyer choice is an inferred privacy convention, not publisher timing. See the dedicated guide for bounded verified native, private, recovery and phone evidence; this is not full-module certification.',
'Ixian Secret Ally uses the exact just-purchased card in supported original standalone entries and fresh classic independent36/Nexus12 starts, optionally all14 Skills. A supported unallied buyer spends its actual Nexus, discards only that purchased card and draws the real deck top privately; Pass keeps both. Full-hand replacement and same-card recycling remain legal, with original payment and continuation once. Native Harkonnen bonus and supported Richese sale/direct origins retain their dedicated source contracts and provisional order; already-owned paid retention creates no new acquisition. Neutral buyer acknowledgement is an inferred convention, not universal publisher timing. Other modules, complete modes and deployed assurance remain separate.',
'Ixian Betrayal has a separate fresh native-Ixian ixian-betrayal profile for both original Bidding inspection/draw and Advanced Technology attempts. Native counters resolve first; then every publicly possible unallied rival held-Nexus seat acknowledges uniformly before the effect. A legal Use spends one physical Ixian Nexus to deny only that attempt; all-pass preserves the native effect once. No completed-card undo or whole-phase ban is added. This bounded ordering/privacy is an inference, not universal publisher priority; see its bounded verified native/private/recovery/phone evidence and unchanged Richese/module/public/deployment gates.',
'With Tleilaxu absent, its actual unallied Nexus holder may use a quiet Revival control to return one mandatory own dead leader free and up to five exact physical counters for one bank spice each. The provisional policy uses the ordinary leader slot and expands the shared ordinary ceiling to at least five, counting prior returns and retaining larger native/Recruits allowance. Only the unopened roster trigger is waived: repeated deaths, actual stars/Cyborgs and special custody retain canonical rules, and ordinary cycle history is not fabricated. Zero forces is legal; force-only use is not. Spend the physical card once; native Tleilaxu income and cancellation are not imported.',
'In Advanced play, Bene Gesserit Cunning converts selected whole advisor territories during your own Shipment and Movement action. All your counters in each selected territory become fighters together across sectors. It spends the Nexus card once, with no spice, shipment or movement cost.',
@@ -0,0 +1,114 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { createIndependentHarkonnenCall, createIndependentIxianPurchase,
reactionAction, reactionPlayer } from './fixture-classic-independent-nexus-reactions';
import { advanceClassicIndependentNexus } from './fixture-classic-independent-nexus';
import { classicDiscoveryNexusClean } from './fixture-discovery-classic-nexus';
import { settleHarkonnenBetrayalNativeCounters } from './fixture-nexus-harkonnen-betrayal';
void test('independent Harass survives a canceled native call and Harkonnen draws the actual Mentat replacement', () => {
const f = createIndependentHarkonnenCall();
const frame = f.game.pendingNexusHarkonnenBetrayal!;
assert.equal(reactionPlayer(f.game, 'atreides').leaders.find(leader => leader.id === f.identity)?.dead, false);
let game = reactionAction(f.game, 'atreides', { type: 'nexusHarkonnenBetrayalUse', event: frame.source.event });
assert.equal(game.nexusCards!.cards!.hands.atreides, null);
assert.deepEqual(game.nexusCards!.cards!.discard, ['harkonnen']);
assert.equal(reactionPlayer(game, 'harkonnen').traitors.includes(f.identity), false);
assert.equal(reactionPlayer(game, 'harkonnen').traitors.length, 3);
assert.equal(game.traitorReserve!.includes(f.identity), true);
assert.equal(reactionPlayer(game, 'atreides').reserves, reactionPlayer(f.beforeShipment, 'atreides').reserves);
assert.equal(reactionPlayer(game, 'atreides').tanks, 0);
assert.equal(reactionPlayer(game, 'atreides').leaders.find(leader => leader.id === f.identity)?.dead, false);
assert.equal(game.discard.filter(card => card.id === HARASS_WITHDRAW_CARD).length, 1);
const top = game.traitorReserve![0];
game = advanceClassicIndependentNexus(game, state => state.phase === 8 && classicDiscoveryNexusClean(state));
assert.equal(game.pendingNexusHarkonnenReplacement, null);
assert.equal(game.nexusHarkonnenReplacementHistory!.length, 1);
assert.equal(game.nexusHarkonnenReplacementHistory![0].drawn, top);
assert.equal(reactionPlayer(game, 'harkonnen').traitors.length, 4);
assert.equal(reactionPlayer(game, 'harkonnen').traitors.filter(identity => identity === top).length, 1);
});
void test('all14 independent Harkonnen Pass preserves the actual traitor outcome and cancels Harass', () => {
const f = createIndependentHarkonnenCall({ advanced: true, skills: true });
const game = reactionAction(f.game, 'atreides', { type: 'nexusHarkonnenBetrayalPass',
event: f.game.pendingNexusHarkonnenBetrayal!.source.event });
assert.equal(game.nexusCards!.cards!.hands.atreides, 'harkonnen');
assert.deepEqual(game.nexusCards!.cards!.discard, []);
assert.equal(reactionPlayer(game, 'harkonnen').traitors.includes(f.identity), true);
assert.equal(reactionPlayer(game, 'harkonnen').traitors.length, 4);
assert.equal(reactionPlayer(game, 'atreides').leaders.find(leader => leader.id === f.identity)?.dead, true);
assert.equal(reactionPlayer(game, 'atreides').tanks, 1);
assert.equal(reactionPlayer(game, 'atreides').reserves, reactionPlayer(f.afterShipment, 'atreides').reserves);
assert.equal(game.discard.filter(card => card.id === HARASS_WITHDRAW_CARD).length, 1);
assert.equal(game.pendingNexusHarkonnenReplacement, null);
assert.deepEqual(game.nexusHarkonnenReplacementHistory, []);
});
void test('all14 printed allied counter precedes independent Harkonnen acknowledgement without Nexus spending', () => {
const f = createIndependentHarkonnenCall({ advanced: true, skills: true, remote: true });
assert.equal(f.game.response?.kind, 'harkonnenTraitor');
assert.equal(f.game.pendingNexusHarkonnenBetrayal, null);
const game = reactionAction(f.game, 'guild', { type: 'card', card: f.counter, mode: 'cancel' });
assert.equal(game.pendingNexusHarkonnenBetrayal, null);
assert.equal(game.pendingNexusHarkonnenReplacement, null);
assert.equal(game.nexusCards!.cards!.hands.atreides, 'harkonnen');
assert.deepEqual(game.nexusCards!.cards!.discard, []);
assert.equal(reactionPlayer(game, 'harkonnen').traitors.includes(f.identity), true);
assert.equal(reactionPlayer(game, 'atreides').leaders.find(leader => leader.id === f.identity)?.dead, false);
assert.equal(reactionPlayer(game, 'atreides').reserves, reactionPlayer(f.beforeShipment, 'atreides').reserves);
assert.equal(game.discard.filter(card => card.id === f.counter).length, 1);
assert.equal(game.discard.filter(card => card.id === HARASS_WITHDRAW_CARD).length, 1);
});
void test('all14 allowed allied call reaches independent Nexus Use before its original battle and Mentat', () => {
const f = createIndependentHarkonnenCall({ advanced: true, skills: true, remote: true });
let game = settleHarkonnenBetrayalNativeCounters(f.game);
const source = game.pendingNexusHarkonnenBetrayal!.source;
assert.equal(source.declaration.beneficiary, 'guild');
game = reactionAction(game, 'atreides', { type: 'nexusHarkonnenBetrayalUse', event: source.event });
assert.equal(reactionPlayer(game, 'atreides').leaders.find(leader => leader.id === f.identity)?.dead, false);
assert.equal(reactionPlayer(game, 'atreides').tanks, 0);
assert.equal(reactionPlayer(game, 'atreides').reserves, reactionPlayer(f.beforeShipment, 'atreides').reserves);
assert.equal(reactionPlayer(game, 'guild').hand.some(card => card.id === f.counter), true);
const top = game.traitorReserve![0];
game = advanceClassicIndependentNexus(game, state => state.phase === 8 && classicDiscoveryNexusClean(state));
assert.equal(game.nexusHarkonnenReplacementHistory!.length, 1);
assert.equal(game.nexusHarkonnenReplacementHistory![0].drawn, top);
assert.equal(reactionPlayer(game, 'harkonnen').traitors.length, 4);
});
void test('all14 Ixian replacement discards the delivered independent lot without repeating its paid invoice', () => {
const f = createIndependentIxianPurchase({ advanced: true, skills: true });
const paid = reactionPlayer(f.game, 'atreides'), top = f.game.deck[0];
assert.equal(paid.spice, reactionPlayer(f.beforePayment, 'atreides').spice - 2);
assert.equal(paid.hand.some(card => card.id === REINFORCEMENTS_CARD), true);
const game = reactionAction(f.game, 'atreides', { type: 'nexusIxianReplacementUse',
event: f.game.pendingNexusIxianReplacement!.event });
assert.equal(reactionPlayer(game, 'atreides').spice, paid.spice);
assert.equal(reactionPlayer(game, 'atreides').hand.length, paid.hand.length);
assert.equal(reactionPlayer(game, 'atreides').hand.some(card => card.id === REINFORCEMENTS_CARD), false);
assert.deepEqual(reactionPlayer(game, 'atreides').hand.find(card => card.id === top.id), top);
assert.equal(game.discard.filter(card => card.id === REINFORCEMENTS_CARD).length, 1);
assert.deepEqual(game.nexusCards!.cards!.discard, ['ixians']);
assert.equal(game.nexusCards!.cards!.hands.atreides, null);
assert.equal(game.auction?.index, f.game.auction!.index + 1);
});
void test('Harkonnen keeps the independent purchase and gains one native bonus when passing Ixian replacement', () => {
const f = createIndependentIxianPurchase({ owner: 'harkonnen' });
const paid = reactionPlayer(f.game, 'harkonnen'), top = f.game.deck[0];
const game = reactionAction(f.game, 'harkonnen', { type: 'nexusIxianReplacementPass',
event: f.game.pendingNexusIxianReplacement!.event });
const buyer = reactionPlayer(game, 'harkonnen');
assert.equal(buyer.spice, reactionPlayer(f.beforePayment, 'harkonnen').spice - 2);
assert.equal(buyer.hand.length, paid.hand.length + 1);
assert.equal(buyer.hand.some(card => card.id === REINFORCEMENTS_CARD), true);
assert.deepEqual(buyer.hand.find(card => card.id === top.id), top);
assert.equal(game.discard.some(card => card.id === REINFORCEMENTS_CARD), false);
assert.equal(game.nexusCards!.cards!.hands.harkonnen, 'ixians');
assert.deepEqual(game.nexusCards!.cards!.discard, []);
assert.equal(game.auction?.index, f.game.auction!.index + 1);
});
@@ -0,0 +1,128 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import { baseDeck } from '../game/cards';
import { territory } from '../game/board';
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { createClassicIndependentNexusFixture, advanceClassicIndependentNexus,
type ClassicIndependentNexusOptions, type ClassicIndependentNexusStep } from './fixture-classic-independent-nexus';
import { applyClassicDiscoveryNexusStep, classicDiscoveryNexusClean,
nextClassicDiscoveryNexusStep } from './fixture-discovery-classic-nexus';
export const reactionPlayer = (game: Game, id: string) => {
const player = game.players.find(seat => seat.id === id);
assert.ok(player);
return player;
};
export function reactionAction(game: Game, actor: string, action: Action, actions?: ClassicIndependentNexusStep[]) {
return applyClassicDiscoveryNexusStep(game, { actor, action }, actions);
}
/** Original setup and turn-one play, followed by a real worm, reciprocal
* alliance and closing draw. Only unused original deck singletons move. */
function drawIndependentReaction(face: 'harkonnen' | 'ixians', owner: string,
options: ClassicIndependentNexusOptions) {
const fixture = createClassicIndependentNexusFixture({ ...options,
roster: ['atreides', 'harkonnen', 'guild'] });
const actions = fixture.actions;
let game = fixture.game;
const take = (worm: boolean) => {
const index = game.spiceDeck.findIndex(card => worm ? 'worm' in card : 'territory' in card);
assert.ok(index >= 0);
return game.spiceDeck.splice(index, 1)[0];
};
game.spiceDeck.unshift(take(false), ...(game.advanced ? [take(false)] : []),
take(true), take(false), ...(game.advanced ? [take(false)] : []));
game = advanceClassicIndependentNexus(game, state => state.turn === 2 && state.nexus &&
!state.spiceWindow && !state.spiceResolution && classicDiscoveryNexusClean(state), actions);
const others = game.players.filter(player => player.id !== owner);
game = reactionAction(game, others[0].id, { type: 'alliance', target: others[1].id }, actions);
game = reactionAction(game, others[1].id, { type: 'alliance', target: others[0].id }, actions);
game = advanceClassicIndependentNexus(game, state => state.nexusCards?.phase?.stage === 'drawing', actions);
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf(face);
assert.ok(index >= 0);
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
const beforeDraw = structuredClone(game);
game = reactionAction(game, owner, { type: 'nexusCardChoice', turn: game.turn,
card: cards.hands[owner], choice: 'draw', ownRedraws: 0 }, actions);
return { ...fixture, game, owner, actions, beforeDraw, afterDraw: structuredClone(game) };
}
export function createIndependentHarkonnenCall(options: { advanced?: boolean; skills?: boolean; remote?: boolean } = {}) {
const counter = baseDeck().find(card => card.effect === 'karama')!.id;
const fixture = drawIndependentReaction('harkonnen', 'atreides', { ...options,
startingCards: { atreides: [HARASS_WITHDRAW_CARD], guild: [counter] } });
const actions = fixture.actions;
let game = advanceClassicIndependentNexus(fixture.game, state => state.phase === 5 &&
state.active === 'atreides' && classicDiscoveryNexusClean(state), actions);
const identity = reactionPlayer(game, 'atreides').leaders.find(leader =>
reactionPlayer(game, 'harkonnen').traitors.includes(leader.id))?.id;
assert.ok(identity, 'Use a real offered opposing Traitor, never assign one.');
const opponent = options.remote ? 'guild' : 'harkonnen';
const location = options.remote ? 'tueks_sietch' : 'carthag';
const sector = territory(location).sectors[0], key = `${location}:${sector}`;
assert.ok(!territory(location).sectors.includes(game.storm));
const beforeShipment = structuredClone(game);
game = reactionAction(game, 'atreides', { type: 'ship', territory: location, sector,
amount: 1, elite: 0, allyPayment: 0 }, actions);
game = advanceClassicIndependentNexus(game, state => state.phase === 6 && classicDiscoveryNexusClean(state), actions);
const afterShipment = structuredClone(game);
game = reactionAction(game, game.active!, { type: 'chooseBattle', territory: location,
target: game.active === 'atreides' ? opponent : 'atreides' }, actions);
for (let count = 0; count < 100; count++) {
const next = nextClassicDiscoveryNexusStep(game);
if (!next) break;
if (next.action.type === 'leaderSkillVisibility') next.action.hide = true;
game = applyClassicDiscoveryNexusStep(game, next, actions);
}
assert.ok(game.battle && !game.battle.revealed && classicDiscoveryNexusClean(game));
const opponentLeader = reactionPlayer(game, opponent).leaders.filter(leader => !leader.dead && !leader.usedAt)
.sort((one, two) => two.strength - one.strength)[0].id;
for (const actor of [game.battle!.attacker, game.battle!.defender]) {
game = reactionAction(game, actor, { type: 'battlePlan', dial: 0, support: 0,
leader: actor === 'atreides' ? identity : opponentLeader,
weapon: actor === 'atreides' ? HARASS_WITHDRAW_CARD : null, defense: null }, actions);
while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') {
const step = nextClassicDiscoveryNexusStep(game); assert.ok(step);
game = applyClassicDiscoveryNexusStep(game, step, actions);
}
}
assert.ok(game.battle?.revealed);
for (const voter of viewGame(game, 'atreides').battle!.traitorVoters)
if (voter !== 'harkonnen' && game.battle!.traitorCalls[voter] === undefined)
game = reactionAction(game, voter, { type: 'traitorCall', call: false }, actions);
const beforeCall = structuredClone(game);
game = reactionAction(game, 'harkonnen', { type: 'traitorCall', call: true }, actions);
return { ...fixture, game, beforeCall, beforeShipment, afterShipment, identity, opponent, key, counter };
}
export function createIndependentIxianPurchase(options: { advanced?: boolean; skills?: boolean; owner?: 'atreides' | 'harkonnen' } = {}) {
const owner = options.owner ?? 'atreides';
const fixture = drawIndependentReaction('ixians', owner, { ...options,
startingCards: { [owner]: ['ecaz-recruits'] } });
const actions = fixture.actions;
let game = advanceClassicIndependentNexus(fixture.game, state => state.phase === 2 && classicDiscoveryNexusClean(state), actions);
const ordered = [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD].map(id => {
const index = game.deck.findIndex(card => card.id === id); assert.ok(index >= 0);
return game.deck.splice(index, 1)[0];
});
game.deck.unshift(...ordered);
game = advanceClassicIndependentNexus(game, state => state.phase === 3 && !!state.auction &&
classicDiscoveryNexusClean(state), actions);
let beforeBid: Game | undefined;
for (let count = 0; game.decision?.kind !== 'auctionPayment' && count < 100; count++) {
if (classicDiscoveryNexusClean(game) && game.auction?.active === owner && game.auction.bid === 0) {
beforeBid = structuredClone(game);
game = reactionAction(game, owner, { type: 'bid', amount: 2, allyPayment: 0 }, actions);
} else {
const step = nextClassicDiscoveryNexusStep(game); assert.ok(step);
game = applyClassicDiscoveryNexusStep(game, step, actions);
}
}
assert.ok(beforeBid);
assert.equal(game.decision?.kind, 'auctionPayment');
assert.equal(game.decision.player, owner);
const beforePayment = structuredClone(game);
game = reactionAction(game, owner, { type: 'decision', karama: false }, actions);
return { ...fixture, game, beforeBid, beforePayment };
}
+6 -1
View File
@@ -25,7 +25,12 @@ export function createClassicIndependentNexusFixture(options: ClassicIndependent
let game = createGame('CLASSICINDEPENDENTNEXUS', newPlayer(roster[0], roster[0], roster[0]), advanced, []);
for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
const actions: ClassicIndependentNexusStep[] = [], staging: string[] = [];
const act = (actor: string, action: Action) => { actions.push({ actor, action }); game = applyAction(game, actor, action); };
const act = (actor: string, action: Action) => {
actions.push({ actor, action });
game = game.status === 'setup'
? applyClassicDiscoveryNexusStep(game, { actor, action })
: applyAction(game, actor, action);
};
for (const option of ['ecazTreachery', 'nexusCards', ...(options.skills ? ['leaderSkills'] : [])])
act(game.host, { type: 'moduleOption', module: option, enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });