diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index db27c80b..6e51f3ec 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -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, diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index cebd2bf3..1def1043 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -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 diff --git a/docs/LEADER_SKILLS_RUNTIME.md b/docs/LEADER_SKILLS_RUNTIME.md index 72c6c764..cd9d02ef 100644 --- a/docs/LEADER_SKILLS_RUNTIME.md +++ b/docs/LEADER_SKILLS_RUNTIME.md @@ -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 diff --git a/docs/NEXUS_CARD_RULES.md b/docs/NEXUS_CARD_RULES.md index 303dcc97..8ac60c92 100644 --- a/docs/NEXUS_CARD_RULES.md +++ b/docs/NEXUS_CARD_RULES.md @@ -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 diff --git a/docs/NEXUS_HARKONNEN_RULES.md b/docs/NEXUS_HARKONNEN_RULES.md index a083defa..750a1e88 100644 --- a/docs/NEXUS_HARKONNEN_RULES.md +++ b/docs/NEXUS_HARKONNEN_RULES.md @@ -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 diff --git a/docs/NEXUS_IXIAN_REPLACEMENT_RULES.md b/docs/NEXUS_IXIAN_REPLACEMENT_RULES.md index a9fc7549..bb17551a 100644 --- a/docs/NEXUS_IXIAN_REPLACEMENT_RULES.md +++ b/docs/NEXUS_IXIAN_REPLACEMENT_RULES.md @@ -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 diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 20f621ee..7231a4a5 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -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) | diff --git a/game/engine.ts b/game/engine.ts index 9aa0b205..3781f8b1 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -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); diff --git a/game/faction-module-profile.ts b/game/faction-module-profile.ts index a8219ad4..a60aa4a9 100644 --- a/game/faction-module-profile.ts +++ b/game/faction-module-profile.ts @@ -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 & Pick & { 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; diff --git a/game/nexus-harkonnen-betrayal.ts b/game/nexus-harkonnen-betrayal.ts index 47de893b..54b24919 100644 --- a/game/nexus-harkonnen-betrayal.ts +++ b/game/nexus-harkonnen-betrayal.ts @@ -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> = { 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(ecazTreacheryCards().map(card => [card.id, card])); const declarationKeys = ['event', 'voter', 'beneficiary', 'target', 'leader', 'identity', 'signature'] as const; const plain = (value: unknown): value is Record => 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)) && diff --git a/game/nexus-ixian-replacement.ts b/game/nexus-ixian-replacement.ts index 39aa02c9..69697f1e 100644 --- a/game/nexus-ixian-replacement.ts +++ b/game/nexus-ixian-replacement.ts @@ -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> = { ...base, ...ix, ...choam, ...cache }; +const independent = catalogue(ecazTreacheryCards()); +const canonical: Readonly> = { ...base, ...ix, ...choam, ...cache, ...independent }; const genesis = 'ixian-replacement:0'; const plain = (value: unknown): value is Record => 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) diff --git a/game/reference.ts b/game/reference.ts index f055e9e9..66368d91 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -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.', diff --git a/tests/classic-independent-nexus-reactions.test.ts b/tests/classic-independent-nexus-reactions.test.ts new file mode 100644 index 00000000..89f8affa --- /dev/null +++ b/tests/classic-independent-nexus-reactions.test.ts @@ -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); +}); diff --git a/tests/fixture-classic-independent-nexus-reactions.ts b/tests/fixture-classic-independent-nexus-reactions.ts new file mode 100644 index 00000000..876bbf68 --- /dev/null +++ b/tests/fixture-classic-independent-nexus-reactions.ts @@ -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 }; +} diff --git a/tests/fixture-classic-independent-nexus.ts b/tests/fixture-classic-independent-nexus.ts index 06b70574..983c808c 100644 --- a/tests/fixture-classic-independent-nexus.ts +++ b/tests/fixture-classic-independent-nexus.ts @@ -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' });