Compose original World Nexus core

This commit is contained in:
Quintine committed 2026-10-18 06:54:54 +11:00
1 parent ce716f3438
commit 8d5c8da6c4
18 files changed
+754 -16

No files matched your search

+3 -2
View File
@@ -1293,8 +1293,9 @@ export function GameTable({
{classicNexusPreview && (
<p className="notice" role="status">
Classic Nexus reactions preview · Basic/Advanced classic factions, original base33
or selected independent36 Treachery, optionally all14 Leader Skills. Original
Tech (3+ seats) and Advanced Strongholds (2+ seats) may compose. Native reactions
or selected independent36 Treachery, optionally all14 Leader Skills. Homeworld/Nexus
core currently keeps base33; independent World cards remain a separate preview boundary.
Original Tech (3+ seats) and Advanced Strongholds (2+ seats) may compose. Native reactions
retain original card, force and payment authority. Acknowledgement timing and
purchase/bonus ordering remain provisional. Other modules and complete Nexus
acceptance remain separate.{' '}
+26
View File
@@ -29,6 +29,32 @@ optional skill bands and combinations remain open.
Rules implementation continues; comprehensive assurance and administration
remain deferred until all rules are implemented.
**Original World Nexus core — 18 October. Prototyped / Partial.**
Plain base33 World/Nexus lacked native reaction states; one public capability
now composes original Homeworlds with base33, optional14 and original
Tech3+/Advanced Strongholds2+. Actual host controls retain every selected
module through private training/Traitors into first Storm; salusa elite5 and
unassigned tokens/cards prove no premature owners. Independent36 World/Nexus
stays a named pending gate; other envelopes remain separate.
Real World fee sources pass their original gates: Guild Junction→Giedi
departure2, Cunning visitor return through native permission/rate/fee2;
Caladan→Carthag borrowed fee1/fee5 settles to the distinct holder with typed
custody waiting. Own-Kaitain temporary-Sardaukar battles keep ordinary-only
casualties and true Salusa custody across high/low support; Giedi native-call
cancellation follows original capture/execution/cleanup and one private
replacement. Normal Smuggler companions now also supply the funded fee in core.
Frozen45-file **448/448**, types/lint/build pass. The initial low-Salusa bound
failure was fixture-only (cap7 vs wallet2) and is asserted honestly; no engine
rule changed. Two actual390px journeys and four legal policies with 13–20
owned choices each pass; no calibration. One HMR route invalidation was
re-entered through its original scene and a DELETE204 cleanup was diagnosed
ERR_ABORTED—diagnostics preserved. Temporary memory/routes/programmes
removed/released204, no saved room/reset/restart/deployment. Independent
World/Nexus, occupation variants, full combinations and assurance stay open.
[Source, scenes and limits](NEXUS_CARD_RULES.md#original-world-nexus-core--18-october-2026).
**Classic core phase modules — 18 October. Prototyped / Partial.**
Fresh classic Nexus now composes original Tech3+ and Advanced Strongholds2+
with selected33/36 and optional14. Normal host controls retain all five
+33
View File
@@ -21,3 +21,36 @@ These quotes are not yet wired into live off-planet battle discovery, aftermath,
Focused checks pass 62 combat/Stone Burner cases, 29 battle-resolution cases, nine Homeworld army/discovery cases and eight Salusa engine cases. These groups overlap existing regression coverage and are not a combined full-suite count. The Salusa cases use genuine Advanced setup, typed shipments and actual battle preparation, sealed plans, traitor actions and casualty settlement. They cover high/low transitions, normal support costs, ordinary Karama, JSON restoration, opponent privacy, physical conservation and legal decisions from every AI level.
The first integrated pass passed **3,218 offline cases**, typecheck, lint, production build and **40 HTTP cases**. Independent review then reproduced a Ghola deadlock: raising Salusa from one to two Sardaukar during combat could invalidate an already sealed paid-support plan. A temporary explicit release boundary now blocks only that population-changing Sardaukar Ghola return during the Emperor’s battle, before any card or force moves. Regressions cover both a sealed plan and a binding prescience dial before either full plan, JSON rejection without mutation, subsequent battle completion and the later legal Sardaukar return. The player view, AI allowance and controls expose the same restriction; ordinary force and leader targets remain available. This is an unfinished interaction, not an official prohibition. The final check after the guard passes **3,221 offline cases**, typecheck and lint; the final production build passes. The 40 HTTP cases passed before the guard; the guard’s new coverage executes production engine actions with JSON restoration. All **2,648** earlier baseline rooms retain their versions and state hashes. No new browser acceptance was performed for this slice. No live Homeworld invasion, complete Homeworld game, full module combination or browser acceptance is claimed by this checkpoint. The recurring automation remains removed.
## Original BASE33 World/Nexus battle programmes — 18 October 2026
The earlier foundation-only descriptions above are historical. The bounded
`fixture-nexus-core-world-battles.ts` programme uses normal original lobby
start/setup, a real turn-two worm/alliance/closing Nexus draw, paid native-source
invasion and the engine's actual battle preparation. It never assigns played
hands, armies, wallets, leaders, clocks, alliances or module ownership. Only
unused original deck/lottery positions select sources.
The Kaitain route moves eight actual ordinary Emperor counters to Salusa and
leaves seven ordinary native defenders. Emperor Cunning keeps its existing
Advanced/five-ordinary/no-actual-Sardaukar eligibility. High Salusa retains five
real stars; the low route legally ships four real stars to empty Arrakis desert,
leaving one on Salusa. The ordinary counters temporarily counting as Sardaukar
do not change that population or become starred casualties. Both routes use the
shared support/casualty calculation and preserve Salusa custody after battle.
The Giedi Prime route invades the actual native Harkonnen pool with Atreides,
uses an originally offered opposing Traitor identity and allows only the native
ordinary Traitor caller. The invader's separately drawn Harkonnen Nexus card
can cancel that call: the physical identity retires, normal combat resumes,
and the original private replacement occurs at Mentat. Advanced optional-all14
routes select the original trained disc through the real visibility control;
original capture/execute and cleanup remain engine-owned. Actually earned
Arrakeen/Carthag/Tuek's Sietch cards do not grant generic own-World effects.
A finite original-action probe exercised high/low Kaitain Cunning and
Basic/Advanced Giedi Prime cancellation, including Advanced capture/execution
and Mentat replacement. The new outcome regressions have not been run in this
lane; parent integration owns verification. No numeric policy or combat helper
change was needed. Independent36 own-World fee/conversion interaction remains
separately gated and is not exercised here.
+27
View File
@@ -16,6 +16,33 @@ no World, counter, wallet, owner or played history is manufactured.
[Latest registered scope and609/609 proof](LEADER_SKILLS_RUNTIME.md#registered-native-e3-selected-decks--18-october-2026);
[prior standalone/paired World459/459 proof](LEADER_SKILLS_RUNTIME.md#native-e3-worlds-with-independent-cards--17-october-2026).
## Original World Nexus payment chronologies — 18 October 2026
Two focused chronologies now exercise genuine original World card-granted
shipment sources against unchanged shared engine helpers. The Basic base33
scene ships Guild's first three native Junction counters to Giedi Prime,
spends the actual Cunning singleton, then returns the same three typed
visitors home: the second declaration opens the real native return permission
and native rate windows before the funded Guild fee gate, and the rival
Harkonnen-face holder cannot Use a second Guild payment. The Advanced
all14/Tech/Strongholds scene ships five actual selected Caladan counters into
Carthag under the original Richese Secret Ally discount and once without it;
the distinct Emperor holder of the real Guild singleton claims the one-spice
discounted fee or the full five-spice ordinary fee, and typed visitor/native
custody, source pools and unrelated seats stay untouched throughout.
Observed through the finite original-action programme
`/tmp/dune-world-nexus-payments-smoke.mts` (`node --import tsx`, exit0):
permission→rate gate kinds, invoice prices 2/1/5 with `originalReceiver`
guild, wallet and reserve deltas only at the owner acknowledgement, the
visitor pool leaving Giedi Prime for Junction without any counter creation,
and the once-only rejection of a repeated `homeworldShip`. New files are
`tests/fixture-nexus-core-world-payments.ts` and
`tests/nexus-core-world-payments-runtime.test.ts`; no shared engine, fixture
or profile file changed. Test runners, types, lint, build, formatters and git
were not run here; the frozen-suite verification remains with the parent.
## Mixed E1/E2 and paired E3 Nexus — 7 October 2026
Original Homeworld/Nexus now composes natives from both selected Ix+CHOAM
+37
View File
@@ -1,5 +1,42 @@
# Dune implementation status
## 18 October 2026 — Original World Nexus core
Plain base33 World/Nexus started without native reaction states, and
independent36 World/Nexus rejected without an explicit boundary message. The
public core now composes original Homeworlds with base33, optional14 and
original Tech/Strongholds; the narrower independent predicate excludes
Homeworlds and both boundary errors now name the pending interaction. Actual
host controls retain all selected modules through original readiness, private
training, Traitor selection and World stock into first Storm (salusa elite5,
no premature owners). Ixian paid2 replacement preserves real World/phase
custody and the following lot.
Two disjoint lanes delivered original World fee and battle sources: a Basic
Guild Junction→Giedi→Cunning-return chronology through native
permission/rate/fee gates, Advanced Caladan→Carthag borrowed fee1/fee5 to the
distinct holder, own-Kaitain temporary-Sardaukar battles with high/low Salusa
support and ordinary-only casualties, and Giedi native-call cancellation with
original capture/execution/cleanup/replacement. Parent integrated pending
World custody bindings, suspended Cunning frame reuse, the Smuggler funded-fee
source and narrower independent messaging; no engine numeric rule changed.
Frozen45-file **448/448**, types/lint/build pass. Initial9/9 failed on a
fixture-only low-Salusa bound assumption (shared cap7 vs wallet2); the worker's
restored high3/low8 rejection assertion and honest comment were accepted.
Nullable World descriptor typing in two new files was corrected after parent
type feedback. Two actual390px journeys and four legal policies (13–20 owned
choices each) cover the lobby, borrowed Caladan fee and both battle families
with varying outcomes; no calibration claim.
One mirror HMR route invalidation reset the isolated scene; it was re-entered
through its original button and completed without gameplay exceptions. A
cleanup DELETE204 was again diagnosed ERR_ABORTED. Temporary routes, memory
and executable programmes are removed/released204; no saved room/reset/
restart/deployment. Existing build warnings, independent World/Nexus,
occupation variants, full combinations and deferred assurance remain open.
[Canonical source and owner proof](NEXUS_CARD_RULES.md#original-world-nexus-core--18-october-2026).
## 18 October 2026 — Classic Nexus with earned phase modules
Original independent36/Nexus+Tech and all14/phase starts rejected through
+63
View File
@@ -2,6 +2,69 @@
Original source audit, 10 September 2026, with dated bounded runtime follow-ups below. This document establishes the printed component inventory, source protocol and implementation boundaries; none lifts expansion release gates. The ordinary worm-triggered alliance Nexus already in the engine is distinct from this optional card module.
## Original World Nexus core — 18 October 2026
**Prototyped / Partial.** Fresh original base33 classic Homeworld+Nexus now
initializes the actual Guild/Harkonnen/Ixian reaction states through the public
core capability, optionally all14 Skills and original Tech3+/Advanced
Strongholds2+. Printed module independence composes; no independent36 cards or
native expansion families are admitted. Independent World/Nexus remains a
separate pending own-World fee/conversion interaction, and public guidance now
says so. Other Discovery/Semuta/reaction envelopes remain separate.
Harkonnen/Ixian pending parents bind only genuinely present World custody; bare
ordinary parents gain no unconditional new keys. Signed Guild Cunning World
frames are admitted at the funded Guild fee gate, reading the actual suspended
frame rather than an invented alias. Normal Smuggler companions also supply the
funded fee: classic core permits the genuine discounted physical invoice while
the original standalone profiles retain their narrower sources. No smuggled
companion can ride a No-Field or card-granted route.
- Basic base33 Guild: real native Junction departure3 to Giedi Prime (printed2)
uses the shipment; real Cunning then returns the same three typed visitors
home through native return permission, native rate and the Guild fee gate.
Wrong-face Emperor cannot Use a second Guild payment; Pass pays the actual2
once, removes the visitor pool and preserves every physical Guild counter.
- Advanced all14+Tech+Strongholds: five actual selected Caladan counters enter
Carthag. The Richese-discounted fee1 and ordinary fee5 both settle to the
distinct actual Guild-card holder; typed custody waits at the gate, and the
used singleton leaves its holder once.
- Own-Kaitain battles: paid Guild invasion plus legal Emperor inter-World
movement leave seven ordinary defenders. Cunning keeps its five-ordinary/
no-actual-Sardaukar rule; temporary-role counters pay support (high Salusa's
real stars support free) but only ordinary counters die, and true Salusa
custody never changes. Low Salusa keeps the shared cap at7 while the wallet
bounds the live plan; a genuine desert star export shows why.
- Giedi Prime: the native pool defends with its actual offered Traitor. Only
Harkonnen receives an ordinary call; the invader's independently drawn
Harkonnen Nexus may cancel it. Original capture/execution, printed bounty,
cleanup and one private actual Mentat replacement follow. Earned
Arrakeen/Carthag/Tuek cards confer no generic World effects.
New original-action files: `tests/fixture-nexus-core-world-payments.ts`,
`tests/nexus-core-world-payments-runtime.test.ts`,
`tests/fixture-nexus-core-world-battles.ts`,
`tests/nexus-core-world-battles-runtime.test.ts`. Frozen45-file **448/448**,
types/lint/build pass. Initial9/9 exposed a fixture-only low-Salusa bound
mistake; the shared cap(7)/wallet(2) distinction is now asserted honestly and
no engine calculation changed. Two workers exercised finite programmes; their
sources/results are preserved in owner proof and removed. Normal Smuggler fee
gates were reproduced then integrated by the owner.
Two actual390px journeys finish the original module-selected Advanced lobby
through genuine training/Traitor/World stock into first Storm (salusa elite5,
no premature token/card owners), and the borrowed Caladan fee mirror:
live Richese offer→guildShipment gate→Emperor Use→payer11/holder11/Guild6,
carthag5, Caladan10→5, discard [richese,guild], Heighliners pending1. Four
legal policies complete13–20 owned choices each across fee and battle paths
with varying winners; no calibration. One browser cleanup DELETE204 was again
diagnosed ERR_ABORTED after HMR route changes, and a route-invalidation reset
was re-entered through its original scene button; diagnostics are reported,
not erased. Temporary routes/memory/programmes removed/released204; no saved
room/reset/restart/deployment. Owner proof:
`/tmp/dune-world-nexus-core-18oct.json`. Full combinations, remaining
occupation variants and deferred assurance remain open.
## Classic Nexus original phase modules — 18 October 2026
**Prototyped / Partial.** Fresh classic Nexus core now retains original
+1
View File
@@ -706,6 +706,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
| 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) |
| Classic Nexus core / funded card shipments | Original Nexus independence admits fresh classic2–6 Basic/Advanced base33 or selected independent36, optionally all14, without a three-card dependency. Initialize actual Guild/Harkonnen/Ixian reaction states only at fresh setup. Original signed Cunning reserve/cross/return and Richese one-counter grants retain native counters, funding and physical continuation through the same Guild fee gate. One Guild singleton cannot simultaneously fund Cunning and rival Use; actual wrong-face Pass remains neutral. Actual third-party Bureaucrat sees the real recipient and printed payer exclusions. No new numerical/universal ruling, played reflag or wider module opening; inherited timing stays provisional. | [Current source and exact proof](NEXUS_CARD_RULES.md#classic-nexus-core-and-funded-card-shipments--18-october-2026) |
| Classic Nexus core / original phase modules | Fresh classic base33 or selected36/Nexus12, optionally14, composes original Tech3–6 Basic/Advanced and Strongholds2–6 Advanced through the public core capability. Real first-Storm tokens, owner-count pending income, actual end-Mentat held cards and original matching payout remain. Fixed paid force groups do not become free under Recruits; free requests accrue once, pending income cannot fund earlier costs, and fees/Harass are not Revival/shipment. Earned local bank support does not fund reserve fees; physical effective Carthag defense, native counters/cleanup/winner token/private replacement keep their original order. No new numerical or universal policy, played reflag or wider family/World/Discovery opening. | [Source, controls and owner-frozen439/439](NEXUS_CARD_RULES.md#classic-nexus-original-phase-modules--18-october-2026) |
| Original World Nexus core | Fresh base33 classic Homeworld/Nexus composes optional all14 and original Tech3+/Advanced Strongholds2+ through the public core capability; independent36 World/Nexus stays a separate pending own-World fee/conversion gate with explicit public messaging. Pending parents bind only present World custody; signed Cunning World frames and genuine discounted Smuggler invoices supply the funded Guild fee while standalone envelopes keep narrower sources. Typed visitor/native pools, printed tariffs, native permission/rate order, singleton spending, temporary-role support and ordinary-only casualties, native Traitor callers and original cleanup/replacement remain. No new numeric policy, played reflag or wider envelope opening. | [Owner-frozen448/448, scenes and limits](NEXUS_CARD_RULES.md#original-world-nexus-core--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) |
+11 -8
View File
@@ -9714,6 +9714,7 @@ function harkonnenNexusBetrayalParent(g: Game): string {
hand:p.hand,traitors:p.traitors,revealedTraitors:p.revealedTraitors,leaders:p.leaders,
forces:p.forces,reserves:p.reserves,tanks:p.tanks,elites:p.elites,advisors:p.advisors})),
deck:g.deck,discard:g.discard,traitorReserve:g.traitorReserve,nexus:g.nexusCards,
...(g.homeworlds ? {homeworlds:g.homeworlds} : {}),
response:g.response,decision:g.decision,truthtrance:g.truthtrance,
phaseOpening:g.phaseOpening,pendingKarama:g.pendingKarama,
pendingNullentropy:g.pendingNullentropy,pendingTreacheryDiscard:g.pendingTreacheryDiscard,
@@ -10043,6 +10044,7 @@ function ixianNexusReplacementParent(g: Game): string {
order:g.order,active:g.active,auction:g.auction,sale:g.currentAuctionSale,
players:g.players.map(p => ({id:p.id,faction:p.faction,ally:p.ally,spice:p.spice,hand:p.hand})),
deck:g.deck,discard:g.discard,aid:g.aid,nexus:g.nexusCards,
...(g.homeworlds ? {homeworlds:g.homeworlds} : {}),
...(byFaction(g, 'richese') ? { richeseBidding:g.richeseBidding,
richeseCache:g.richeseCache,richeseRemoved:g.richeseRemoved,
...(g.richeseAuction ? { richeseAuction:g.richeseAuction } : {}) } : {}),
@@ -10269,8 +10271,8 @@ function guildBetrayalAuthority(g: Game, continuation: GuildBetrayalContinuation
'richesePair', 'noField', 'alliedNoField', 'noFieldSkillProof'].includes(key)),
'The saved reserve shipment contains foreign source fields.');
requireRule(!shipment.source && !shipment.noField && !shipment.alliedNoField &&
!shipment.smuggler && !shipment.smugglerCompanion && !shipment.richesePair &&
!shipment.guildSecretEvent &&
!shipment.richesePair && !shipment.guildSecretEvent &&
((shipment.smuggler && classicNexusCoreProfile(g)) || (!shipment.smuggler && !shipment.smugglerCompanion)) &&
((!shipment.guildNexusEvent && !shipment.nexusEvent) || classicNexusCoreProfile(g)),
'This Guild Betrayal profile requires a supported original physical shipment source.');
validatePhysicalShipment(g, shipment);
@@ -10302,8 +10304,8 @@ function guildBetrayalAuthority(g: Game, continuation: GuildBetrayalContinuation
['player', 'destination', 'sources', 'route', 'event', 'turn', 'amount', 'elite', 'cost',
'allyPayment', 'pools', 'guildSecretEvent', 'guildNexusEvent'].includes(key)),
'The saved Homeworld shipment contains foreign source fields.');
requireRule(!shipment.guildSecretEvent && !shipment.guildNexusEvent,
'This Guild Betrayal profile supports native Homeworld shipment routes only.');
requireRule(!shipment.guildSecretEvent && (!shipment.guildNexusEvent || classicNexusCoreProfile(g)),
'This Guild Betrayal profile requires a supported original Homeworld shipment source.');
const quote = validateHomeworldShipment(g, shipment);
shipper = shipment.player; price = quote.cost; allyPayment = shipment.allyPayment;
originalReceiver = getPlayer(g, shipper).ally !== guild.id && allyPayment > 0 ? guild.id : null;
@@ -10740,7 +10742,7 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game {
const homeworlds = homeworldLeaderSkillsProfile(state);
const independent = independentTreacheryLeaderSkillsProfile(state);
if (state.ecazTreachery) {
requireRule(independent, 'Independent Skills cards require supported selected decks with original World/phase modules, or classic Nexus with original phase modules; Discovery and unrelated preview contexts remain gated.');
requireRule(independent, 'Independent Skills cards require supported selected decks with original World/phase modules, or classic Nexus with original phase modules without Homeworlds; independent World/Nexus and unrelated preview contexts remain gated.');
requireFreshBaseRuntime(state);
requireFreshFactionInventory(state);
}
@@ -10826,7 +10828,7 @@ export function initializeEcazTreacheryGameForAudit(state: Game): Game {
g.ecazTreachery = true;
const homeworlds = !!g.homeworlds;
requireRule(!homeworlds || ecazHomeworldProfile(g),
'Independent cards with Homeworlds need a fresh supported selected-deck roster with original Tech or Advanced Strongholds.');
'Independent World cards need a supported selected-deck roster and original phase modules without Nexus; independent World/Nexus remains a separate pending interaction.');
const initialized = initializeSetupGameForAudit(g, homeworlds, classicIndependentNexusProfile(g), false, false, false, false, true, true);
if (byFaction(initialized, 'ecaz')) initialized.ecazOccupyPreview = true;
if (moritaniAssassinateModeSupported(initialized)) initializeMoritaniAssassinateState(initialized);
@@ -28236,8 +28238,9 @@ function nexusGuildCunningIntegrity(g: Game) {
for (const d of decisions) validateGuildShipmentDecision(g,d);
for (const rate of rates) validateGuildRateResponse(g,rate);
} else if (active.shipment.kind === 'homeworld') {
requireRule(g.pendingHomeworldShipment?.guildNexusEvent === active.receipt.event,'Guild Cunning lost its pending Homeworld shipment.');
validateGuildCunningShipment(g,owner.id,active.receipt.event,'homeworld',g.pendingHomeworldShipment);
const pending = g.pendingHomeworldShipment ?? (suspended?.source === 'homeworld' ? suspended.shipment : null);
requireRule(pending?.guildNexusEvent === active.receipt.event,'Guild Cunning lost its pending Homeworld shipment.');
validateGuildCunningShipment(g,owner.id,active.receipt.event,'homeworld',pending);
homeworldShipmentIntegrity(g);
} else if (active.shipment.kind === 'guild') {
requireRule(suspended?.source === 'guildTransport', 'Guild Cunning lost its original transport payment acknowledgement.');
+2 -2
View File
@@ -31,12 +31,12 @@ type ClassicNexusProfile = Partial<FactionModuleProfile> & Pick<FactionModulePro
};
/** Classic Nexus12 with selected base33/independent36, optional all14 and original phase modules.
* Native Guild/Harkonnen/Ixian effect stamps are not additional selected modules. */
* Original Homeworld core uses base33; native effect stamps are not selected overlays. */
export function classicNexusCoreProfile(game: ClassicNexusProfile): boolean {
const players = game.players;
if (typeof game.advanced !== 'boolean' || !game.nexusCards ||
game.expansions.length !== 0 || !players || players.length < (game.techTokens ? 3 : 2) || players.length > 6 ||
game.homeworlds || (game.strongholdCards && !game.advanced) || game.sandtrout ||
(game.homeworlds && game.ecazTreachery) || (game.strongholdCards && !game.advanced) || game.sandtrout ||
game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker ||
game.semutaPreview || game.moritaniAssassinatePreview || game.advancedPreview || game.kullPreview || game.nexusKullPreview ||
game.richeseBetrayalPreview ||
+1
View File
@@ -463,6 +463,7 @@ function homeworldLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean
/** Original Homeworld/Skills under the existing classic or native Nexus roster contracts. */
export function homeworldLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
if (!game.homeworlds) return false;
if (classicNexusCoreProfile(game)) return true;
if (game.expansions.length) return nativeExpansionLeaderSkillsProfile(game);
if (!homeworldLeaderSkillModulesSupported(game)) return false;
const players = game.players!;
+2 -1
View File
@@ -4104,7 +4104,8 @@ export const RULE_TOPICS: RuleTopic[] = [
'Caladan shares the admitted original Bidding inspection with the occupier, not its ally; native inspection can still be prevented independently. Ix gives the occupier the actual private drawn pool and selection, not unrelated setup or Technology. Richese sets its own method/direction before the occupier chooses only the physical cache card; seller, payment and other decisions stay native. Giedi gives one original purchase bonus to the occupier or its reciprocal ally with real receiver capacity, even when Harkonnen’s purchased card fills its hand.',
'The separately marked fresh Tupile entry grants one hand slot to the proved occupier and reciprocal ally. Every server-derived hand limit includes it, so Harkonnen can hold nine without receiving a tenth bonus. After a proved loss, each old holder privately selects exactly its excess original eligible cards to return to the normal limit; committed cards remain protected. Original phase opening and remaining movement resume without another shipment or payment. Basic unknown lifecycle blocks rather than guesses cleanup. Advanced departure can restore CHOAM’s low intelligence without clearing its previous answers or used factions.',
'The fresh native Ecaz high-card preview shows a separate prospective conjunction: native seven-plus population, at least one actual jointly held stronghold and proved current alliance holdings of two other native factions’ Homeworlds. This does not change the ordinary four-stronghold or three-joint Ecaz targets. Original Mentat decides the win before prediction/fallback; source uncertainty remains blocked. Duke authority and the Ecaz-only revival ruling are separate.',
'The card collection below is available for reference. Starting a Homeworld game remains disabled while shipment, battles, occupation, economy and faction interactions are being completed.',
'The card collection below is available for reference. Homeworld starts are enabled in supported fresh preview envelopes; the remaining occupation variants and combinations stay under verification.',
'Original base33 World/Nexus now composes optional all14, Tech3+ and Advanced Strongholds2+ with real first-Storm token owners and end-Mentat card claims. Guild World shipments and Cunning visitor returns pass native permission/rate before the funded fee; borrowed Caladan fee1/fee5 settles to the distinct holder while typed custody waits. Temporary-Sardaukar own-Kaitain battles keep ordinary-only casualties and true Salusa custody; Giedi native-call cancellation follows original capture/execution/cleanup and one private replacement. Normal Smuggler companions may also supply the fee in core. Independent36 World/Nexus remains a separate pending own-World fee/conversion gate with public messaging; other envelopes stay separate.',
],
example:
'Atreides with six reserves on Caladan uses the high face. Dropping to five uses the low face and removes the next-Spice-Blow inspection advantage. Either face adds two to native battle strength on Caladan.',
@@ -49,7 +49,10 @@ export function drawClassicNexusReaction(face: FactionId, owner: string,
return { ...fixture, game, owner, actions, beforeDraw, afterDraw: structuredClone(game) };
}
export function createIndependentHarkonnenCall(options: { advanced?: boolean; skills?: boolean; remote?: boolean; independent?: boolean; tech?: boolean; strongholds?: boolean } = {}) {
type ClassicNexusReactionOptions = Pick<ClassicIndependentNexusOptions,
'advanced' | 'skills' | 'independent' | 'tech' | 'strongholds' | 'homeworlds'>;
export function createIndependentHarkonnenCall(options: ClassicNexusReactionOptions & { remote?: boolean } = {}) {
const counter = baseDeck().find(card => card.effect === 'karama')!.id;
const starting = options.independent === false ? baseDeck().find(card => card.kind === 'worthless')!.id : HARASS_WITHDRAW_CARD;
const fixture = drawClassicNexusReaction('harkonnen', 'atreides', { ...options,
@@ -98,7 +101,7 @@ export function createIndependentHarkonnenCall(options: { advanced?: boolean; sk
return { ...fixture, game, beforeCall, beforeShipment, afterShipment, identity, opponent, key, counter };
}
export function createIndependentIxianPurchase(options: { advanced?: boolean; skills?: boolean; owner?: 'atreides' | 'harkonnen'; independent?: boolean; tech?: boolean; strongholds?: boolean } = {}) {
export function createIndependentIxianPurchase(options: ClassicNexusReactionOptions & { owner?: 'atreides' | 'harkonnen' } = {}) {
const owner = options.owner ?? 'atreides';
const starting = options.independent === false ? baseDeck().find(card => card.kind === 'worthless')!.id : 'ecaz-recruits';
const fixture = drawClassicNexusReaction('ixians', owner, { ...options,
@@ -16,6 +16,7 @@ export type ClassicIndependentNexusOptions = {
independent?: boolean;
tech?: boolean;
strongholds?: boolean;
homeworlds?: boolean;
roster?: readonly FactionId[];
training?: Partial<Record<FactionId, LeaderSkillId>>;
/** Only original, still-undealt starting Treachery slots are ordered. */
@@ -40,6 +41,7 @@ export function createClassicIndependentNexusFixture(options: ClassicIndependent
act(game.host, { type: 'moduleOption', module: option, enabled: true });
if (options.tech) act(game.host, { type: 'techTokens', enabled: true });
if (options.strongholds) act(game.host, { type: 'strongholdCards', enabled: true });
if (options.homeworlds) act(game.host, { type: 'homeworlds', enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
const rolls = options.skills ? skillOfferRolls(game, options.training) : [];
+1 -1
View File
@@ -7,7 +7,7 @@ import { drawClassicNexusReaction, reactionAction, reactionPlayer } from './fixt
import { advanceClassicIndependentNexus, type ClassicIndependentNexusOptions, type ClassicIndependentNexusStep } from './fixture-classic-independent-nexus';
import { classicDiscoveryNexusClean } from './fixture-discovery-classic-nexus';
export type ClassicNexusCoreOptions = Pick<ClassicIndependentNexusOptions, 'advanced' | 'skills' | 'independent' | 'training' | 'tech' | 'strongholds'>;
export type ClassicNexusCoreOptions = Pick<ClassicIndependentNexusOptions, 'advanced' | 'skills' | 'independent' | 'training' | 'tech' | 'strongholds' | 'homeworlds'>;
function drawSecondFace(game: Game, owner: string, face: FactionId, actions: ClassicIndependentNexusStep[]) {
assert.equal(game.nexusCards!.phase!.stage, 'drawing');
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf(face);
+147
View File
@@ -0,0 +1,147 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import { TERRITORIES } from '../game/board';
import { combatArmy } from '../game/combat-location';
import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import { drawClassicNexusReaction, reactionAction, reactionPlayer } from './fixture-classic-independent-nexus-reactions';
import { advanceClassicIndependentNexus, type ClassicIndependentNexusStep } from './fixture-classic-independent-nexus';
import { classicDiscoveryNexusClean, nextClassicDiscoveryNexusStep } from './fixture-discovery-classic-nexus';
export type WorldNexusBattleStep = ClassicIndependentNexusStep;
export type WorldNexusBattleOptions = { advanced?: boolean; skills?: boolean; tech?: boolean; strongholds?: boolean };
type WorldNexusInvasion = { before: Game; action: Action; cost: number; after: Game };
export const worldNexusClean = (game: Game) => classicDiscoveryNexusClean(game) &&
!game.pendingGuildBetrayal && !game.pendingNexusHarkonnenBetrayal && !game.pendingNexusIxianBetrayal;
export { reactionPlayer as worldNexusPlayer };
/** Continue only existing public controls. Human sealed plans are never invented.
* The named trained disc is concealed through the actual visibility choice. */
export function advanceWorldNexusBattle(game: Game, until: (game: Game) => boolean,
actions: WorldNexusBattleStep[] = [], trained: readonly string[] = []): Game {
for (let count = 0; count < 1200; count++) {
if (until(game)) return game;
if (game.pendingGuildBetrayal) {
game = advanceClassicIndependentNexus(game, state => !state.pendingGuildBetrayal, actions);
continue;
}
const step = nextClassicDiscoveryNexusStep(game);
if (step?.action.type === 'leaderSkillVisibility') step.action.hide = trained.includes(step.actor);
if (step) game = reactionAction(game, step.actor, step.action, actions);
else game = advanceClassicIndependentNexus(game, until, actions);
}
throw Error('Original World Nexus battle did not reach its owned boundary');
}
function chooseWorldBattle(game: Game, location: string, actions: WorldNexusBattleStep[], trained: readonly string[]) {
const choice = viewGame(game, game.active!).battleChoices.find(battle => battle.territory === location);
assert.ok(choice, 'The paid invasion must produce its actual World battle');
game = reactionAction(game, choice.chooser, { type: 'chooseBattle', territory: location,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker }, actions);
return advanceWorldNexusBattle(game, state => !!state.battle && !state.battle.revealed &&
!state.battle.preparation && state.battle.preLeader?.closed !== false && worldNexusClean(state), actions, trained);
}
function invade(game: Game, actor: string, location: string, amount: number, actions: WorldNexusBattleStep[]): WorldNexusInvasion {
const before = structuredClone(game), view = viewGame(game, actor);
const sources = nativeShipmentSources(view, amount, 0); assert.ok(sources);
const quote = homeworldShipmentChoice(view, location,
Object.fromEntries(Object.entries(sources).filter(([, force]) => force.normal + force.elite > 0)));
assert.ok(quote.action, quote.blocked ?? 'Use the original paid native invasion');
game = reactionAction(game, actor, quote.action, actions);
game = advanceWorldNexusBattle(game, state => !state.pendingHomeworldShipment && worldNexusClean(state), actions);
return { before, action: quote.action, cost: quote.cost, after: game };
}
/** Genuine closing draw, paid Guild invasion and Emperor's legal inter-World
* movement leave seven ordinary Kaitain counters. The optional low Salusa
* route exports four actual stars to an empty desert, never converts them. */
export function createWorldNexusEmperorBattle(options: WorldNexusBattleOptions & { salusa?: 'high' | 'low' } = {}) {
const fixture = drawClassicNexusReaction('emperor', 'emperor', { ...options, advanced: true,
independent: false, homeworlds: true, roster: ['atreides', 'guild', 'emperor'],
training: options.skills ? { emperor: 'planetologist', atreides: 'warmaster', guild: 'sandmaster' } : undefined });
const actions = fixture.actions, location = 'homeworld:emperor', trained = options.skills ? ['emperor'] : [];
let game = advanceWorldNexusBattle(fixture.game, state => state.phase === 5 && worldNexusClean(state), actions);
let arrival: WorldNexusInvasion | undefined;
let moved = false, exported = false;
while (game.phase === 5) {
const actor = game.active!;
if (worldNexusClean(game) && actor === 'guild' && !reactionPlayer(game, actor).shipped) {
arrival = invade(game, actor, location, 3, actions); game = arrival.after;
} else if (worldNexusClean(game) && actor === 'emperor' && !moved) {
if (options.salusa === 'low' && !exported) {
const desert = TERRITORIES.find(territory => territory.type === 'sand' && !territory.sectors.includes(game.storm) &&
!game.players.some(player => Object.keys(player.forces).some(key => key.startsWith(`${territory.id}:`))));
assert.ok(desert);
const sources = nativeShipmentSources(viewGame(game, actor), 4, 4); assert.ok(sources);
game = reactionAction(game, actor, { type: 'ship', territory: desert.id, sector: desert.sectors[0],
amount: 4, elite: 4, homeworldSources: sources, allyPayment: 0 }, actions);
exported = true;
game = advanceWorldNexusBattle(game, worldNexusClean, actions);
} else {
const movement = viewGame(game, actor).homeworldMove; assert.ok(movement && !movement.blocked);
game = reactionAction(game, actor, { type: 'emperorHomeworldMove', event: movement.event,
origin: location, normal: 8, elite: 0 }, actions);
moved = true;
}
} else {
const step = nextClassicDiscoveryNexusStep(game); assert.ok(step);
game = reactionAction(game, step.actor, step.action, actions);
}
}
assert.ok(arrival && moved);
game = advanceWorldNexusBattle(game, state => state.phase === 6 && worldNexusClean(state), actions);
const beforeBattle = structuredClone(game);
game = chooseWorldBattle(game, location, actions, trained);
const leaders = reactionPlayer(game, 'emperor').leaders.filter(leader => !leader.dead && !leader.usedAt);
const trainer = game.leaderSkills?.assignments.find(assignment => assignment.owner === 'emperor');
const leader = trainer ? leaders.find(leader => leader.id === trainer.leader)! : leaders.at(-1)!;
const opponentLeader = reactionPlayer(game, 'guild').leaders.filter(leader => !leader.dead && !leader.usedAt)
.sort((a, b) => a.strength - b.strength)[0];
assert.ok(leader && opponentLeader);
assert.deepEqual(combatArmy(game, 'emperor', location), { normal: 7, elite: 0 });
homeworldGameIntegrity(game);
const plans: WorldNexusBattleStep[] = [
{ actor: 'emperor', action: { type: 'battlePlan', dial: 4, support: options.salusa === 'low' ? 2 : 0, leader: leader.id } },
{ actor: 'guild', action: { type: 'battlePlan', dial: 0, support: 0, leader: opponentLeader.id } },
];
return { ...fixture, game, beforeBattle, arrival, location, plans, trainer, leader: leader.id };
}
/** The original Harkonnen native pool defends Giedi Prime. Its actual offered
* Atreides Traitor is chosen as the invader's disc; only Harkonnen receives an
* ordinary call, while the invader owns the independently drawn cancel card. */
export function createWorldNexusHarkonnenBattle(options: WorldNexusBattleOptions = {}) {
const fixture = drawClassicNexusReaction('harkonnen', 'atreides', { ...options,
independent: false, homeworlds: true, roster: ['atreides', 'harkonnen', 'guild'],
training: options.skills ? { harkonnen: 'planetologist', atreides: 'warmaster', guild: 'sandmaster' } : undefined });
const actions = fixture.actions, location = 'homeworld:harkonnen';
let game = advanceWorldNexusBattle(fixture.game, state => state.phase === 5 && state.active === 'atreides' &&
worldNexusClean(state), actions);
const identity = reactionPlayer(game, 'atreides').leaders.find(leader => reactionPlayer(game, 'harkonnen').traitors.includes(leader.id))?.id;
assert.ok(identity, 'Use an actually offered native Traitor identity');
const arrival = invade(game, 'atreides', location, 1, actions); game = arrival.after;
game = advanceWorldNexusBattle(game, state => state.phase === 6 && worldNexusClean(state), actions);
const beforeBattle = structuredClone(game), trained = options.skills ? ['harkonnen'] : [];
game = chooseWorldBattle(game, location, actions, trained);
const trainer = game.leaderSkills?.assignments.find(assignment => assignment.owner === 'harkonnen');
const leader = trainer ? reactionPlayer(game, 'harkonnen').leaders.find(leader => leader.id === trainer.leader)!
: reactionPlayer(game, 'harkonnen').leaders.toSorted((a, b) => b.strength - a.strength)[0];
const plans: WorldNexusBattleStep[] = [
{ actor: 'atreides', action: { type: 'battlePlan', dial: 0, support: 0, leader: identity } },
{ actor: 'harkonnen', action: { type: 'battlePlan', dial: 0, support: 0, leader: leader.id } },
];
homeworldGameIntegrity(game);
return { ...fixture, game, beforeBattle, arrival, location, identity, plans, trainer, leader: leader.id };
}
export function revealWorldNexusPlans(game: Game, plans: readonly WorldNexusBattleStep[], actions: WorldNexusBattleStep[] = []) {
for (const plan of plans) {
game = reactionAction(game, plan.actor, plan.action, actions);
game = advanceWorldNexusBattle(game, state => worldNexusClean(state) && !!state.battle &&
!state.battle.preparation && state.battle.preLeader?.closed !== false, actions);
}
assert.ok(game.battle?.revealed);
return game;
}
+122
View File
@@ -0,0 +1,122 @@
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 type { FactionId } from '../game/catalog';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { nativeShipmentSources } from '../game/homeworld-options';
import { drawClassicNexusReaction, reactionAction, reactionPlayer } from './fixture-classic-independent-nexus-reactions';
import { advanceClassicIndependentNexus, type ClassicIndependentNexusStep } from './fixture-classic-independent-nexus';
import type { ClassicNexusCoreOptions } from './fixture-classic-nexus-core';
import { classicDiscoveryNexusClean, nextClassicDiscoveryNexusStep } from './fixture-discovery-classic-nexus';
export type WorldNexusPaymentOptions = Pick<ClassicNexusCoreOptions, 'advanced' | 'skills' | 'tech' | 'strongholds'>;
/** Select only the still-undealt original singleton, then use its actual closing draw. */
function drawWorldPaymentFace(game: Game, owner: string, face: FactionId, actions: ClassicIndependentNexusStep[]) {
assert.equal(game.nexusCards!.phase!.stage, 'drawing');
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf(face);
assert.ok(index >= 0);
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
return reactionAction(game, owner, { type: 'nexusCardChoice', turn: game.turn,
card: cards.hands[owner], choice: 'draw', ownRedraws: 0 }, actions);
}
export function worldPaymentWorld(game: Game, actor: string, native: string) {
const worlds = viewGame(game, actor).homeworlds?.worlds ?? [];
const world = worlds.find(home => home.native === native && !home.secondary);
assert.ok(world, `Seat ${native} must hold its actual primary World descriptor.`);
return world;
}
/** Basic original base33 chronology by default: an actual Junction departure,
* actual Cunning, then an actual visitor return from Giedi Prime to Junction.
* The second route therefore needs native return permission as well as the
* native rate; neither a scalar reserve nor an Arrakis sector is relabelled. */
export function createWorldNexusCunningPayment(options: WorldNexusPaymentOptions = {}) {
const counter = baseDeck().find(card => card.effect === 'karama')!.id;
const fixture = drawClassicNexusReaction('guild', 'guild', { ...options,
independent: false, homeworlds: true, roster: ['atreides', 'harkonnen', 'guild', 'emperor'],
startingCards: { atreides: [counter] } });
const actions = fixture.actions;
let game = drawWorldPaymentFace(fixture.game, 'emperor', 'harkonnen', actions);
game = advanceClassicIndependentNexus(game, state => state.phase === 5 && state.active === 'guild' &&
classicDiscoveryNexusClean(state), actions);
const source = worldPaymentWorld(game, 'guild', 'guild');
const destination = worldPaymentWorld(game, 'guild', 'harkonnen');
const amount = 3, sources = { [source.id]: { normal: amount, elite: 0 } };
assert.ok(source.forces.guild.normal >= amount);
const firstQuote = homeworldShipmentChoice(viewGame(game, 'guild'), destination.id, sources, 0);
assert.equal(firstQuote.blocked, null);
assert.ok(firstQuote.action);
const beforeFirst = structuredClone(game), firstAction = firstQuote.action;
game = reactionAction(game, 'guild', firstAction, actions);
game = advanceClassicIndependentNexus(game, state => reactionPlayer(state, 'guild').shipped &&
!state.pendingGuildBetrayal && classicDiscoveryNexusClean(state), actions);
const afterFirst = structuredClone(game), offer = viewGame(game, 'guild').nexusGuildCunning!.offer!;
assert.equal(offer.blocked, null);
game = reactionAction(game, 'guild', { type: 'endMovement', nexus: offer.event }, actions);
game = advanceClassicIndependentNexus(game, state => state.nexusGuildCunningHistory?.at(-1)?.stage === 'secondShipment' &&
classicDiscoveryNexusClean(state), actions);
const beforeSecond = structuredClone(game);
const returnSource = worldPaymentWorld(game, 'guild', 'harkonnen');
const returnDestination = worldPaymentWorld(game, 'guild', 'guild');
assert.equal(returnSource.forces.guild.normal, amount, 'Only the first shipment\'s actual visitors occupy Giedi Prime.');
const returnSources = { [returnSource.id]: { normal: amount, elite: 0 } };
const quote = homeworldShipmentChoice(viewGame(game, 'guild'), returnDestination.id, returnSources, 0);
assert.equal(quote.blocked, null);
assert.ok(quote.action);
return { ...fixture, game: beforeSecond, beforeFirst, afterFirst, beforeSecond, actions, counter, amount,
source: source.id, destination: destination.id, sources, firstAction, firstQuote,
returnSources, quote, action: quote.action, actor: 'guild', holder: 'emperor' };
}
/** Actual normal/Richese shipment: Caladan's selected native counters enter
* Carthag, while Emperor holds the distinct original Guild singleton. Advanced
* all14/Tech/Strongholds is the default second chronology, not a copied scene. */
export function createWorldNexusBorrowedPayment(options: WorldNexusPaymentOptions & { discounted?: boolean } = {}) {
const discounted = options.discounted ?? true;
const fixture = drawClassicNexusReaction(discounted ? 'richese' : 'harkonnen', 'atreides', {
advanced: true, skills: true, tech: true, strongholds: true, ...options,
independent: false, homeworlds: true, roster: ['atreides', 'guild', 'harkonnen', 'emperor'],
training: options.skills === false ? undefined
: { atreides: 'diplomat', guild: 'mentat', harkonnen: 'warmaster', emperor: 'sandmaster' },
});
const actions = fixture.actions;
let game = drawWorldPaymentFace(fixture.game, 'emperor', 'guild', actions);
game = advanceClassicIndependentNexus(game, state => state.phase === 5 && state.active === 'atreides' &&
classicDiscoveryNexusClean(state), actions);
const amount = 5, place = territory('carthag');
assert.ok(!place.sectors.includes(game.storm));
const native = worldPaymentWorld(game, 'atreides', 'atreides');
const sources = nativeShipmentSources(viewGame(game, 'atreides'), amount, 0);
assert.ok(sources);
assert.deepEqual(sources[native.id], { normal: amount, elite: 0 });
const action: Action = { type: 'ship', territory: place.id, sector: place.sectors[0],
amount, elite: 0, allyPayment: 0, homeworldSources: sources };
if (discounted) {
const offer = viewGame(game, 'atreides').nexusRichese!;
assert.equal(offer.blocked, null);
action.nexus = offer.event;
}
return { ...fixture, game, beforeShipment: structuredClone(game), actions, action, amount,
sources, source: native.id, key: `${place.id}:${place.sectors[0]}`, actor: 'atreides',
holder: 'emperor', discounted };
}
/** The original native gates between a World declaration and its fee: each
* recorded kind is a real live window stepped by the shared controller, never
* a synthetic phase label. Stops before the owner fee acknowledgement. */
export function worldNexusPaymentGates(game: Game, actor: string, action: Action,
actions?: ClassicIndependentNexusStep[]) {
const kinds: string[] = [];
game = reactionAction(game, actor, action, actions);
for (let count = 0; !game.pendingGuildBetrayal && count < 40; count++) {
kinds.push(game.response?.kind ?? game.decision?.kind ?? `${game.phase}/${game.active}`);
const step = nextClassicDiscoveryNexusStep(game);
assert.ok(step, 'The original controller must own every native gate or arrival choice.');
game = reactionAction(game, step.actor, step.action, actions);
}
assert.ok(game.pendingGuildBetrayal, 'The original declaration must reach its Guild fee gate.');
return { game, kinds };
}
@@ -0,0 +1,140 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { applyAction, viewGame } from '../game/engine';
import { combatArmy } from '../game/combat-location';
import { casualtyOptions, maxCombatDial, maxCombatSupport } from '../game/combat';
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
import { homeworldPopulations } from '../game/homeworld-population';
import { reactionAction } from './fixture-classic-independent-nexus-reactions';
import {
advanceWorldNexusBattle, createWorldNexusEmperorBattle, createWorldNexusHarkonnenBattle,
revealWorldNexusPlans, worldNexusClean, worldNexusPlayer,
} from './fixture-nexus-core-world-battles';
for (const salusa of ['high', 'low'] as const) {
void test(`original own-Kaitain Cunning with ${salusa} Salusa spends support but kills only physical ordinary counters`, () => {
const f = createWorldNexusEmperorBattle({ salusa, skills: true, tech: true, strongholds: true });
let game = f.game;
const emperor = worldNexusPlayer(game, 'emperor'), guild = worldNexusPlayer(game, 'guild');
const beforeSpice = emperor.spice, beforeStars = structuredClone(emperor.elites);
const beforeSalusa = structuredClone(game.homeworlds!.custody!.salusa);
const populations = homeworldPopulations(homeworldContext(game), game.homeworlds!.custody!);
const home = populations.find(home => home.location === f.location)!;
assert.deepEqual([home.population, home.side, home.nativeBattleStrength], [7, 'high', 2]);
assert.deepEqual(beforeSalusa, { normal: 8, elite: salusa === 'high' ? 5 : 1 });
assert.equal(populations.find(home => home.card === 'salusa_secundus')!.side, salusa);
assert.equal(f.trainer!.leader, f.leader);
assert.equal(game.battle!.leaderSkillHidden!.emperor, true);
assert.equal(f.plans[0].action.leader, f.trainer!.leader);
// Arrakeen and Tuek's Sietch were actually earned at END Mentat; neither
// becomes generic off-planet strength or support in this native battle.
assert.equal(game.strongholdCards!.owners.arrakeen, 'atreides');
assert.equal(game.strongholdCards!.owners.tueks_sietch, 'guild');
assert.deepEqual(viewGame(game, 'emperor').battle!.strongholdEffects, { guild: null, emperor: null });
const offer = viewGame(game, 'emperor').nexusSardaukar!.offer!;
assert.equal(offer.blocked, null);
game = reactionAction(game, 'emperor', { type: 'nexusSardaukar', event: offer.event });
game = advanceWorldNexusBattle(game, state => worldNexusClean(state));
const forces = viewGame(game, 'emperor').battle!.ownForces!;
assert.deepEqual([forces.normal, forces.elite, forces.temporaryElite], [7, 0, 5]);
assert.equal(!!forces.eliteFreeSupport, salusa === 'high');
assert.equal(maxCombatDial(forces), 12);
assert.equal(maxCombatSupport(forces), salusa === 'high' ? 2 : 7);
assert.ok(casualtyOptions(forces, 4, salusa === 'high' ? 0 : 2).some(loss => loss.normal === 2 && loss.elite === 0));
// High Salusa's five real stars grant free elite support, so only the two
// ordinary non-temporary counters can pay (shared cap 2). Low Salusa lifts
// the shared counter cap to 7, but the desert export has already left the
// Emperor 2 spice, so the live plan bound stays 0–2 and anything above it
// is rejected unchanged.
const badSupport = salusa === 'high' ? 3 : 8;
assert.throws(() => applyAction(game, 'emperor', { ...f.plans[0].action, support: badSupport }));
assert.throws(() => applyAction(game, 'emperor', { type: 'nexusSardaukar', event: offer.event }));
game = revealWorldNexusPlans(game, f.plans);
assert.deepEqual(viewGame(game, 'emperor').battle!.traitorVoters, ['emperor']);
assert.throws(() => applyAction(game, 'guild', { type: 'traitorCall', call: false }));
game = advanceWorldNexusBattle(game, state => !state.battle && !state.homeworldBattleLoss && worldNexusClean(state));
assert.equal(game.lastBattleContext!.winner, 'emperor');
assert.equal(game.lastBattleContext!.result, 'normal');
assert.deepEqual(combatArmy(game, 'emperor', f.location), { normal: 5, elite: 0 });
assert.deepEqual(combatArmy(game, 'guild', f.location), { normal: 0, elite: 0 });
assert.equal(worldNexusPlayer(game, 'emperor').tanks, emperor.tanks + 2);
assert.equal(worldNexusPlayer(game, 'guild').tanks, guild.tanks + 3);
assert.equal(worldNexusPlayer(game, 'emperor').spice, beforeSpice - (salusa === 'high' ? 0 : 2));
assert.deepEqual(worldNexusPlayer(game, 'emperor').elites, beforeStars);
assert.deepEqual(game.homeworlds!.custody!.salusa, beforeSalusa);
const record = game.nexusSardaukarHistory!.at(-1)!;
assert.equal(record.casualties!.outcome, 'complete');
assert.ok(record.casualties!.options.every(loss => loss.elite === 0));
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
assert.equal(viewGame(game, 'emperor').nexusSardaukar!.active, false);
homeworldGameIntegrity(game);
});
}
for (const advanced of [false, true]) {
for (const cancel of [false, true]) {
void test(`${advanced ? 'Advanced all14/phase' : 'Basic'} Giedi Prime native call ${cancel ? 'is canceled with retirement and replacement' : 'retains its original traitor outcome'}`, () => {
const f = createWorldNexusHarkonnenBattle({ advanced, skills: advanced, tech: advanced, strongholds: advanced });
let game = revealWorldNexusPlans(f.game, f.plans);
const native = worldNexusPlayer(game, 'harkonnen'), invader = worldNexusPlayer(game, 'atreides');
const home = homeworldPopulations(homeworldContext(game), game.homeworlds!.custody!).find(home => home.location === f.location)!;
assert.deepEqual([home.population, home.side, home.nativeBattleStrength], [10, 'high', 2]);
assert.deepEqual(viewGame(game, 'atreides').battle!.traitorVoters, ['harkonnen']);
assert.throws(() => applyAction(game, 'atreides', { type: 'traitorCall', call: true }));
if (advanced) {
assert.equal(game.strongholdCards!.owners.carthag, 'harkonnen');
assert.deepEqual(viewGame(game, 'harkonnen').battle!.strongholdEffects, { harkonnen: null, atreides: null });
assert.equal(game.battle!.plans.harkonnen.leader, f.trainer!.leader);
}
game = reactionAction(game, 'harkonnen', { type: 'traitorCall', call: true });
const event = game.pendingNexusHarkonnenBetrayal!.source.event;
assert.equal(viewGame(game, 'atreides').nexusHarkonnenBetrayalReaction!.canUse, true);
assert.equal(game.pendingNexusHarkonnenBetrayal!.source.declaration.identity, f.identity);
game = reactionAction(game, 'atreides', { type: cancel ? 'nexusHarkonnenBetrayalUse' : 'nexusHarkonnenBetrayalPass', event });
if (cancel) {
assert.equal(worldNexusPlayer(game, 'harkonnen').traitors.includes(f.identity), false);
assert.equal(game.traitorReserve!.filter(identity => identity === f.identity).length, 1);
assert.equal(game.pendingNexusHarkonnenReplacement!.identity, f.identity);
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'harkonnen').length, 1);
}
let captive: string | undefined;
if (advanced) {
game = advanceWorldNexusBattle(game, state => state.decision?.kind === 'captureOffer' && !state.response);
assert.equal(game.decision!.kind, 'captureOffer');
game = reactionAction(game, 'harkonnen', { type: 'decision', accept: true });
game = advanceWorldNexusBattle(game, state => state.decision?.kind === 'capturedLeader' && !state.response);
assert.ok(game.decision?.kind === 'capturedLeader');
captive = game.decision.leader;
assert.equal(worldNexusPlayer(game, 'atreides').leaders.find(leader => leader.id === captive)!.capturedBy, 'harkonnen');
const spice = worldNexusPlayer(game, 'harkonnen').spice;
game = reactionAction(game, 'harkonnen', { type: 'decision', mode: 'execute' });
assert.equal(worldNexusPlayer(game, 'harkonnen').spice, spice + 2);
const executed = worldNexusPlayer(game, 'atreides').leaders.find(leader => leader.id === captive)!;
assert.equal(executed.dead, true);
assert.equal(executed.deaths, 2);
assert.equal(executed.capturedBy, undefined);
}
const replacementTop = cancel ? game.traitorReserve![0] : null;
game = advanceWorldNexusBattle(game, state => state.phase === 8 && worldNexusClean(state));
assert.equal(game.lastBattleContext!.territory, f.location);
assert.equal(game.lastBattleContext!.winner, 'harkonnen');
assert.equal(game.lastBattleContext!.result, cancel ? 'normal' : 'traitor');
assert.deepEqual(combatArmy(game, 'harkonnen', f.location), { normal: 10, elite: 0 });
assert.deepEqual(combatArmy(game, 'atreides', f.location), { normal: 0, elite: 0 });
assert.equal(worldNexusPlayer(game, 'harkonnen').tanks, native.tanks);
assert.equal(worldNexusPlayer(game, 'atreides').tanks, invader.tanks + 1);
assert.equal(game.pendingNexusHarkonnenReplacement, null);
assert.equal(game.nexusHarkonnenReplacementHistory!.length, cancel ? 1 : 0);
if (cancel) {
assert.equal(game.nexusHarkonnenReplacementHistory![0].drawn, replacementTop);
assert.equal(worldNexusPlayer(game, 'harkonnen').traitors.length, 4);
} else {
assert.equal(game.nexusCards!.cards!.hands.atreides, 'harkonnen');
assert.ok(worldNexusPlayer(game, 'harkonnen').revealedTraitors!.includes(f.identity));
assert.equal(worldNexusPlayer(game, 'atreides').leaders.find(leader => leader.id === f.identity)!.dead, true);
}
assert.throws(() => applyAction(game, 'atreides', { type: 'nexusHarkonnenBetrayalUse', event }));
homeworldGameIntegrity(game);
});
}
}
@@ -0,0 +1,131 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { viewGame, type Game } from '../game/engine';
import { reactionAction, reactionPlayer } from './fixture-classic-independent-nexus-reactions';
import {
createWorldNexusBorrowedPayment,
createWorldNexusCunningPayment,
worldNexusPaymentGates,
} from './fixture-nexus-core-world-payments';
const custody = (game: Game, id: string) => {
const seat = reactionPlayer(game, id);
return { forces: seat.forces, reserves: seat.reserves, elites: seat.elites ?? null, tanks: seat.tanks };
};
const worldForces = (game: Game, actor: string, id: string) => {
const world = (viewGame(game, actor).homeworlds?.worlds ?? []).find(entry => entry.id === id);
assert.ok(world);
return world.forces;
};
const guildCounterTotal = (game: Game) => {
const guild = reactionPlayer(game, 'guild');
const visitors = (viewGame(game, 'guild').homeworlds?.worlds ?? [])
.filter(world => world.native !== 'guild');
return guild.reserves + Object.values(guild.forces).reduce((sum, n) => sum + n, 0) +
visitors.reduce((sum, world) => sum + (world.forces.guild?.normal ?? 0) + (world.forces.guild?.elite ?? 0), 0);
};
void test('Basic base33 World Cunning return passes native permission, native rate and the funded Guild fee gate', () => {
const f = createWorldNexusCunningPayment();
assert.equal(f.game.phase, 5);
assert.equal(f.game.active, 'guild');
assert.equal(reactionPlayer(f.game, 'guild').shipped, true, 'The first Junction departure already used the shipment.');
assert.equal(f.firstQuote.cost, 2);
assert.equal(f.quote.cost, 2);
assert.equal(f.quote.amount, 3);
assert.equal(f.quote.ownPayment, 2);
assert.equal(f.action.type, 'homeworldShip');
assert.equal(f.action.destination, 'homeworld:guild');
assert.deepEqual(f.action.sources, { 'homeworld:harkonnen': { normal: 3, elite: 0 } });
assert.equal(f.game.nexusGuildCunningHistory!.at(-1)!.stage, 'secondShipment');
assert.equal(viewGame(f.game, 'guild').nexusGuildCunning!.active!.movesLeft, 0,
'The granted Cunning move is reserved for this second shipment, not normal movement.');
const unrelated = ['atreides', 'harkonnen', 'emperor'].map(id => [id, custody(f.game, id)] as const);
const gates = worldNexusPaymentGates(f.game, 'guild', f.action);
assert.deepEqual(gates.kinds, ['guildTransportPermission', 'guildRate'],
'The return route opens both native gates before any fee.');
const invoice = gates.game.pendingGuildBetrayal!.invoice;
assert.equal(invoice.price, 2);
assert.equal(invoice.ownSpice, 3, 'No escrow is taken before the fee acknowledgement.');
assert.deepEqual(gates.game.pendingGuildBetrayal!.required, ['emperor']);
assert.equal(reactionPlayer(gates.game, 'guild').reserves, 12);
assert.deepEqual(worldForces(gates.game, 'guild', 'homeworld:harkonnen').guild, { normal: 3, elite: 0 });
assert.equal(viewGame(gates.game, 'emperor').guildBetrayalReaction!.canUse, false,
'The original Guild singleton was already spent on Cunning itself.');
assert.throws(() => reactionAction(gates.game, 'emperor',
{ type: 'guildBetrayalUse', event: invoice.event }), /cannot take this shipment payment/);
let game = reactionAction(gates.game, 'emperor', { type: 'guildBetrayalPass', event: invoice.event });
const guild = reactionPlayer(game, 'guild');
assert.equal(worldForces(game, 'guild', 'homeworld:harkonnen').guild, undefined,
'The actual visitor pool leaves Giedi Prime.');
assert.deepEqual(worldForces(game, 'guild', 'homeworld:guild').guild, { normal: 15, elite: 0 });
assert.equal(guild.reserves, 15);
assert.equal(guildCounterTotal(game), guildCounterTotal(gates.game), 'No physical Guild counter is created or lost.');
assert.deepEqual(game.nexusCards!.cards!.discard, ['guild']);
assert.equal(game.nexusCards!.cards!.hands.emperor, 'harkonnen');
for (const [id, before] of unrelated) assert.deepEqual(custody(game, id), before);
game = reactionAction(game, 'guild', { type: 'endMovement' });
assert.equal(game.nexusGuildCunningHistory!.at(-1)!.stage, 'completed');
assert.equal(viewGame(game, 'guild').nexusGuildCunning ?? null, null);
assert.notEqual(game.active, 'guild');
assert.throws(() => reactionAction(game, 'guild', f.action), /unused shipment/,
'The once-only card, payment and physical continuation cannot repeat.');
});
void test('Advanced all14 Richese borrowed Caladan shipment pays the distinct Guild holder its one-spice fee', () => {
const f = createWorldNexusBorrowedPayment();
assert.equal(f.source, 'homeworld:atreides');
assert.deepEqual(f.sources, { 'homeworld:atreides': { normal: 5, elite: 0 } });
assert.equal(f.action.type, 'ship');
assert.equal(typeof f.action.nexus, 'string');
assert.deepEqual(worldForces(f.game, 'atreides', 'homeworld:atreides').atreides, { normal: 10, elite: 0 });
const unrelated = ['guild', 'harkonnen', 'emperor'].map(id => [id, custody(f.game, id)] as const);
const gates = worldNexusPaymentGates(f.game, 'atreides', f.action);
assert.deepEqual(gates.kinds, ['guildShipment']);
const invoice = gates.game.pendingGuildBetrayal!.invoice;
assert.equal(invoice.price, 1);
assert.equal(invoice.originalReceiver, 'guild');
assert.deepEqual(gates.game.pendingGuildBetrayal!.required, ['emperor']);
assert.equal(reactionPlayer(gates.game, 'atreides').reserves, 10, 'Typed custody waits for the fee.');
assert.equal(reactionPlayer(gates.game, 'atreides').forces[f.key] ?? 0, 0);
assert.equal(viewGame(gates.game, 'emperor').guildBetrayalReaction!.canUse, true,
'The distinct actual holder may still claim this fee.');
const game = reactionAction(gates.game, 'emperor', { type: 'guildBetrayalUse', event: invoice.event });
assert.equal(reactionPlayer(game, 'atreides').spice, reactionPlayer(gates.game, 'atreides').spice - 1);
assert.equal(reactionPlayer(game, 'emperor').spice, reactionPlayer(gates.game, 'emperor').spice + 1);
assert.equal(reactionPlayer(game, 'guild').spice, reactionPlayer(gates.game, 'guild').spice);
assert.equal(reactionPlayer(game, 'atreides').forces[f.key], 5);
assert.equal(reactionPlayer(game, 'atreides').reserves, 5);
assert.deepEqual(worldForces(game, 'atreides', 'homeworld:atreides').atreides, { normal: 5, elite: 0 });
assert.deepEqual(game.nexusCards!.cards!.discard, ['richese', 'guild']);
assert.equal(game.nexusRicheseHistory!.at(-1)!.stage, 'shipped');
for (const [id, before] of unrelated) assert.deepEqual(custody(game, id), before);
});
void test('the ordinary Advanced all14 Caladan shipment pays its full five-spice fee to the distinct Guild holder', () => {
const f = createWorldNexusBorrowedPayment({ discounted: false });
assert.equal(f.action.nexus, undefined);
const gates = worldNexusPaymentGates(f.game, 'atreides', f.action);
assert.deepEqual(gates.kinds, ['guildShipment']);
const invoice = gates.game.pendingGuildBetrayal!.invoice;
assert.equal(invoice.price, 5);
assert.deepEqual(gates.game.pendingGuildBetrayal!.required, ['atreides', 'emperor']);
assert.equal(viewGame(gates.game, 'emperor').guildBetrayalReaction!.canUse, true);
const game = reactionAction(gates.game, 'emperor', { type: 'guildBetrayalUse', event: invoice.event });
assert.equal(reactionPlayer(game, 'atreides').spice, reactionPlayer(gates.game, 'atreides').spice - 5);
assert.equal(reactionPlayer(game, 'emperor').spice, reactionPlayer(gates.game, 'emperor').spice + 5);
assert.equal(reactionPlayer(game, 'guild').spice, reactionPlayer(gates.game, 'guild').spice);
assert.equal(reactionPlayer(game, 'atreides').forces[f.key], 5);
assert.equal(reactionPlayer(game, 'atreides').reserves, 5);
assert.deepEqual(game.nexusCards!.cards!.discard, ['guild']);
assert.equal(game.nexusCards!.cards!.hands.emperor, null,
'The used Guild singleton leaves its holder once; no duplicate exists.');
assert.equal(game.nexusCards!.cards!.hands.atreides, 'harkonnen',
'The rival Harkonnen face stays held by its own drawing seat.');
});