From 0db84c81b78240fdf1e2b1729c8ff27e773c34db Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:14:39 +1100 Subject: [PATCH] Connect native Guild rate cancellation and Cunning receipts --- docs/CURRENT_STATUS.md | 20 +++ docs/DEFERRED_SHIPMENT_ARRIVAL.md | 6 +- docs/GUILD_RATE_KARAMA_RULES.md | 40 ++++++ docs/GUILD_SHIPMENT_PAYMENTS.md | 13 +- docs/IMPLEMENTATION_STATUS.md | 39 ++++++ docs/NEXUS_GUILD_CUNNING_RUNTIME.md | 17 +++ docs/RULE_DECISIONS.md | 2 +- game/engine.ts | 65 ++++++--- game/reference.ts | 19 +-- ...fixture-guild-ambassador-income-arrival.ts | 6 +- tests/native-guild-rate-karama.test.ts | 131 ++++++++++++++++++ tests/nexus-guild-cunning-engine.test.ts | 56 ++++++++ 12 files changed, 375 insertions(+), 39 deletions(-) create mode 100644 tests/native-guild-rate-karama.test.ts diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 50b4d28e..22242575 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,26 @@ optional skill bands, combinations and public expansion starts remain open. Rules implementation continues; comprehensive assurance and administration remain deferred until all rules are implemented. +**Native Guild rate cancellation, including Cunning — 15 October.** +Expansion/Nexus rosters now retain the prepayment native rate window for +reserve/cross/return declarations. Original physical group, tariff and approved +funding remain fixed. Affordable cancellation pays full price once; unaffordable +cancellation spends only Karama and preserves a full-price replacement. +Cunning retains its true original shipped history and second allowance; +its reserve receipt follows the pending rate event and settled price. +No first shipment, ordinary movement or Nexus reopens. + +Affected nine-file101/101 and five action-only original API/policy paths pass. +All four policies pass the native rate response and pay two for three here. +Three actual390px journeys prove full-price transport, no-debt withdrawal/ +replacement and Cunning reserve repricing through turn completion; no overflow +or browser errors. Two scenes are action-only native; Cunning is its explicitly +controlled existing fixture. Homeworld/Skills/Discovery/Tech/Stronghold rate +composition, full assurance and public starts remain open, not silently certified. +[Current contract and original/controlled evidence distinction](GUILD_RATE_KARAMA_RULES.md#native-expansion-and-nexus-admission--15-october-2026). +Final types/lint and production build pass, with existing deprecation/chunk/ +Unknown-route warnings. Current contract links and checklist evidence resolve. + **Native Guild/BG own-arrival continuations — 15 October.** Direct Guild transport's owner-view candidate now matches its executable income-before-queued-Ambassador path. Ordinary shipment stops predicting its diff --git a/docs/DEFERRED_SHIPMENT_ARRIVAL.md b/docs/DEFERRED_SHIPMENT_ARRIVAL.md index 9c6d7a43..b53b3cda 100644 --- a/docs/DEFERRED_SHIPMENT_ARRIVAL.md +++ b/docs/DEFERRED_SHIPMENT_ARRIVAL.md @@ -16,9 +16,9 @@ entry queue, not a second timing adapter, waits for those native controls. Original price, funding, payment, source group, reserve transfer and movement ownership stay unchanged. Actual response/decision/Terror/Ambassador controls, pre-entry fighter choice, Fremen storm protection and unsupported paired -reaction/profile guards remain. The separate Guild **rate** Karama profile -gate is unchanged and remains follow-up work; income cancellation is not -rate cancellation. +reaction/profile guards remain. The subsequent [native rate follow-up](GUILD_RATE_KARAMA_RULES.md#native-expansion-and-nexus-admission--15-october-2026) +also admits expansion/Nexus rate responses while retaining original price +and unaffordable-return policies. Income cancellation itself is not repricing. **Provisional composition, not publisher priority:** finish the arrival's own controls before its deferred entry. Accompaniment joining existing BG diff --git a/docs/GUILD_RATE_KARAMA_RULES.md b/docs/GUILD_RATE_KARAMA_RULES.md index 3065f84e..88ee0b3e 100644 --- a/docs/GUILD_RATE_KARAMA_RULES.md +++ b/docs/GUILD_RATE_KARAMA_RULES.md @@ -2,6 +2,46 @@ Initial primary-source and engine audit, 2026-09-06. The bounded ordinary-reserve cancellation policy below was selected on 2026-09-28 after a targeted publisher-source search found no affordability procedure; the later original audit remains historical evidence for unfinished transport and module combinations. +## Native expansion and Nexus admission — 15 October 2026 + +The native prepayment rate window now admits expansion factions and Nexus +without requiring a classic-only roster/deck. Homeworlds, Leader Skills, +Discovery, Tech Tokens and Stronghold Cards retain separate rate integration +gates. Special source/No-Field/Smuggler/borrowed-price receipts remain distinct. +An actual discount must exist: one force to a stronghold costs one at either +rounded rate and has no discount to remove. + +Original Ecaz/Moritani/Guild setup, real Karama auction, paid token and actual +Moritani/Guild alliance reproduce the missing window: transport3 previously +paid two and moved immediately into income. Now the exact group waits at +half2/full3 before any debit or transfer. Allow retains two; a real Karama +charges three or, if unaffordable, spends only that card and returns the +uncommitted attempt. A full-price replacement cannot regain the discount. +The existing approved contribution split and provisional no-debt/return +policies are unchanged; rate, permission and income remain separate. + +**Cunning uses the same opportunity, not a cleared flag.** Its original +`shipped` history remains true. Both rate validators use source-aware +`shipmentAvailable`. The pending reserve receipt binds its temporary rate +event and later settled price; rate allowance/cancellation reaches the +original second-shipment completion. Unaffordable release removes only the +uncommitted frame, retaining that second allowance for a full-price replacement. +The first shipment, ordinary movement and spent Nexus never reopen. + +Affected nine-file **101/101** passes, including seven action-only native +reserve/cross/return cases and six Cunning reserve/cross allow/cancel/ +unaffordable cases using its existing explicitly controlled fixture. +Five action-only original API/policy paths pass; all four policies pass +the native window and pay two in this position. A separate controlled +Cunning declaration proves the originally failing true-`shipped` transition. + +Three actual styled390px GameTable journeys perform full-price cancellation, +unaffordable withdrawal/replacement and Cunning reserve repricing through +Finish Guild turn. The first two use the action-only native producer; the +Cunning scene explicitly uses its older controlled final-setup fixture. +No overflow/browser errors. Loopback/in-memory only: no saved/deployed +room, recovery certification, module/public gate or full-mode claim. + ## Implemented payment follow-up The shared contributor-income calculation and private Guild transport quotes were integrated in the earlier payment checkpoint. November 2020 direct contribution routing credits a non-Guild ally’s contribution when Guild ships; Guild’s own contribution still goes to the bank. The source adjudication, earlier 1,600-check checkpoint and browser evidence are in [GUILD_SHIPMENT_PAYMENTS.md](GUILD_SHIPMENT_PAYMENTS.md). That checkpoint preceded the bounded rate-cancellation path below. diff --git a/docs/GUILD_SHIPMENT_PAYMENTS.md b/docs/GUILD_SHIPMENT_PAYMENTS.md index aa66947d..0c03339f 100644 --- a/docs/GUILD_SHIPMENT_PAYMENTS.md +++ b/docs/GUILD_SHIPMENT_PAYMENTS.md @@ -2,6 +2,15 @@ Integrated checkpoint, 2026-09-07 local date. This closes the contributor-routing defect and adds transport price controls; ordinary rate/permission Karama cancellation and nonbattle Truthtrance commitments remain incomplete. +## Native rate admission follow-up — 15 October 2026 + +The separate prepayment **rate** window now also admits expansion/Nexus native +reserve, cross-planet and Guild-return declarations, including Cunning's +original second allowance. Affordable full repricing and unaffordable return/ +full-price replacement retain original sources and approved funding. +Three actual390px journeys prove these controls; income remains distinct. +[Canonical source, evidence and remaining physical-module gates](GUILD_RATE_KARAMA_RULES.md#native-expansion-and-nexus-admission--15-october-2026). + ## Native direct transport continuation — 15 October 2026 Direct `guildShip` already committed positive Guild income before queued @@ -11,8 +20,8 @@ using reserves. Real Karama income cancellation prevents Guild's receipt without refunding the shipper or repeating transport; the original Ecaz offer and next movement owner follow. Allow/decline and cancellation variants pass in the [native arrival contract](DEFERRED_SHIPMENT_ARRIVAL.md#native-own-arrival-continuations--15-october-2026). -The separate native **rate** Karama expansion/Nexus profile gate is unchanged -and remains implementation work, not a printed prohibition or exercised path. +The subsequent native rate follow-up above supersedes this checkpoint's +expansion/Nexus exclusion; income cancellation itself is still not repricing. ## Ordinary income then Ambassador arrival — 15 October 2026 diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 99d186a8..2a0c76cf 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,44 @@ # Dune implementation status +## 15 October 2026 — Native Guild rate admission and Cunning receipts + +Removed classic-only expansion/Nexus exclusions from native rate admission, +retaining physical-module and special price/source gates. The action-only +Ecaz/Moritani/Guild producer demonstrates the old immediate paid transport3 +and new prepayment half2/full3 response. Original contribution and price +validation, provisional no-debt withdrawal and full-price replacement remain. + +Expanded admission exposed Cunning's old `!shipped` rate guard: its original +first shipment is rightly still recorded. Both rate validators now reuse +`shipmentAvailable`. Cunning's reserve frame binds the temporary rate event +and actual settlement; unaffordable cancellation releases only the current +uncommitted frame. Existing interception and rate windows are recognized, +without reopening the first shipment, ordinary movement or spent Nexus. + +Affected nine-file **101/101** passes: seven action-only native reserve/cross/ +return cases, six controlled Cunning reserve/cross allow/cancel/unaffordable +cases and existing rate/transport/Cunning/Betrayal/own-arrival regressions. +Five original API/policy paths pass. All four actual policies pass the new +native rate window and pay rounded two in this position, not cancel. +A separate controlled Cunning smoke reproduces/fixes the true-shipped failure. + +Three actual styled390px GameTable journeys exercise full-price cancellation, +unaffordable withdrawal/full-price replacement and Cunning reserve repricing +through Finish Guild turn. First transport pays Mori12→9/Guild6→9; the +poor source stays6/spice3 until its replacement3 pays full3. Cunning retains +first Arrakeen5, adds Carthag3 and ends at14 spice/12 reserves. No overflow/ +browser errors. Two scenes use the original action-only native fixture; +Cunning uses its explicitly controlled older final-setup fixture. + +Homeworlds, Skills, Discovery, Tech, Strongholds and special borrowed/free +sources remain separately bounded. No saved/deployed room, reset, provider +permission, comprehensive assurance or public-start gate changed. +[Canonical contract](GUILD_RATE_KARAMA_RULES.md#native-expansion-and-nexus-admission--15-october-2026). +Final `npm run check:quick` and production build pass after expressing the +validated non-declined receipt type without removing optional release. +Existing module-register/chunk/Unknown-route warnings remain; six current +local links and the five-facet canonical evidence targets resolve. + ## 15 October 2026 — Native Guild/BG own-arrival continuations Direct native Guild transport's engine already committed income before queued diff --git a/docs/NEXUS_GUILD_CUNNING_RUNTIME.md b/docs/NEXUS_GUILD_CUNNING_RUNTIME.md index 2fef6555..aa496569 100644 --- a/docs/NEXUS_GUILD_CUNNING_RUNTIME.md +++ b/docs/NEXUS_GUILD_CUNNING_RUNTIME.md @@ -2,6 +2,23 @@ This checkpoint follows the [Guild source contract](NEXUS_GUILD_RULES.md): native Guild can spend Cunning when finishing its ordinary Shipment and Movement turn to obtain one second shipment. It preserves one combined turn and the actual history of the first shipment and movement. It does not implement Guild Secret Ally, Betrayal or complete Nexus play. +## Native rate response integration — 15 October 2026 + +Without Homeworlds, Skills, Discovery, Tech or Strongholds, the second native +reserve/cross shipment now retains ordinary Guild rate cancellation in +expansion/Nexus games. Its original `shipped` history stays true; the shared +source-aware allowance validates both rate paths. The reserve frame follows +its exact pending rate event and actual settled price. Unaffordable cancellation +releases only that uncommitted attempt, permitting a full-price second attempt +without granting another first turn, ordinary move or Nexus. + +Six controlled original-grant engine cases cover reserve/cross allow, cancel +and unaffordable replacement in the existing final-setup fixture. The actual +390px reserve journey allows Guild's own interception, cancels half2 to full3, +retains the first five at Arrakeen, places three at Carthag and finishes the +original Guild turn at14 spice/12 reserves. No overflow/browser errors. +[Current rate contract, provenance and remaining module gates](GUILD_RATE_KARAMA_RULES.md#native-expansion-and-nexus-admission--15-october-2026). + ## Opportunity and controls The [shared consumer helper](../game/nexus-guild-cunning-options.ts) binds the private native holder, unallied card, current turn, actor and projected event. The [table](../components/game-table.tsx) offers an explicit “Finish ordinary turn and declare Guild Cunning” action alongside ordinary finishing. It spends the card and opens the existing Karama response mechanism. A cancellation ends the original turn without undoing its committed shipment or movement; allowance offers the separate shipment. diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 90f9effa..d9186b98 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -669,7 +669,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP | Source and readiness | Separate published rules, explicit implementation choices and unfinished interpretations; Advanced/expansion gates stay closed. | [Base/Advanced audit](BASE_ADVANCED_READINESS_20260907.md), [checklist](../game/reference.ts) | | Setup and Fremen movement | Preserve source-ordered setup and the distinct declared-movement cancellation; do not recreate fixed historical gaps. | [Setup contract](ADVANCED_SETUP_TEST_SEAM.md), [Fremen Karama](FREMEN_MOVEMENT_KARAMA_RULES.md) | | Guild payment | Rate, transport permission and income are distinct; power owner, shipper, contributors and independent rate-card beneficiary remain distinct. | [Rate/cancellation sources](GUILD_RATE_KARAMA_RULES.md), [implemented payment routing](GUILD_SHIPMENT_PAYMENTS.md) | -| Guild native rate Karama | In bounded classic-faction Basic/Advanced tables, a separate prepayment response can cancel Guild’s or its ally’s half rate on an ordinary reserve shipment or eligible cross-planet transport, and Guild’s own return. The original physical cohort and approved contributor split must cover full destination price; otherwise the Karama is spent, the declaration returns unused, and a replacement this turn uses full price. One spice per returned force is the conservative doubled-tariff interpretation without a return-specific FAQ example. Independent Karama-purchased rates, Guild income, transport permission and optional modules remain separate. | [Publisher distinctions and selected reserve/transport policy](GUILD_RATE_KARAMA_RULES.md#native-transport-extension--29-september-2026) | +| Guild native rate Karama | Native Basic/Advanced reserve, eligible cross-planet and Guild-return shipments now admit expansion/Nexus rosters without Homeworlds, Skills, Discovery, Tech or Strongholds. Cunning uses its original signed second allowance and true shipped history; its reserve frame binds the rate event and settled price. The same physical cohort and approved split must fund full price after cancellation; otherwise spend Karama and return only the uncommitted attempt, retaining a full-price replacement. One spice per returned force and no-debt withdrawal remain the earlier conservative policies, not new publisher examples. Income, transport permission, borrowed prices and remaining module integration are distinct. | [Native expansion/Nexus evidence and scope](GUILD_RATE_KARAMA_RULES.md#native-expansion-and-nexus-admission--15-october-2026) | | Deferred shipment arrival | Quote unsupported ordinary arrivals before saving Guild interception. A validated legacy ordinary declaration may return unspent on the typed arrival error; corrupted declarations and special parent/card receipts are not silently cleared. This repairs continuation without deciding simultaneous reaction priority. | [Reproduction and bounded recovery](DEFERRED_SHIPMENT_ARRIVAL.md) | | Native Ambassador own-arrival continuation | Under the standing choose-recommended-option instruction, reuse committed entry after its own Guild income, BG Intrusion and accompaniment controls. Existing callbacks put a legitimate accompanying BG fighter child before the deferred original entrant; each sees the then-live token, so declining the child preserves a later original offer and consuming it does not recreate a token. Advisors remain exempt. This child-first/live-token composition is provisional, not a located publisher priority ruling. Actual busy controls, unsupported paired/module guards and separate native rate-window admission remain distinct. | [Native original-action evidence and boundary](DEFERRED_SHIPMENT_ARRIVAL.md#native-own-arrival-continuations--15-october-2026) | | Truthtrance | Current facts are boolean disclosures, not promises to preserve resources. Structured Basic and base Advanced active-opportunity shipment promises bind within their documented scope (Advanced excludes Guild and optional modules); prose is not automatically enforced. | [Spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card facts](TRUTHTRANCE_CARD_COUNT.md), [shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md), [remaining audit](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md) | diff --git a/game/engine.ts b/game/engine.ts index cb661032..6d05feab 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -26485,9 +26485,8 @@ const CLASSIC_FACTIONS: Partial> = { /** The independent Karama rate is a card benefit, not Guild's cancelable rate. */ function guildRateModeSupported(g: Game): boolean { - return !g.expansions.length && !g.homeworlds && !g.leaderSkills && - !g.nexusCards && !g.discoveryEnabled && !g.techTokens && !g.strongholdCards && - g.players.every(player => CLASSIC_FACTIONS[player.faction] === true); + return !g.homeworlds && !g.leaderSkills && !g.discoveryEnabled && + !g.techTokens && !g.strongholdCards; } function ordinaryGuildRateFullCost(g: Game, shipment: PendingShipment): number | null { const guild = byFaction(g, 'guild'); @@ -26498,7 +26497,7 @@ function ordinaryGuildRateFullCost(g: Game, shipment: PendingShipment): number | (g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === p.id) || shipment.source || shipment.noField || shipment.alliedNoField || shipment.smuggler || shipment.smugglerCompanion || shipment.guildSecretEvent || - shipment.guildNexusEvent || shipment.nexusEvent || shipment.richesePair) + shipment.nexusEvent || shipment.richesePair) return null; const fullCost = reserveShipmentCost({ faction: p.faction, halfRate: false }, territory(shipment.territory).type, shipment.amount); @@ -26507,10 +26506,17 @@ function ordinaryGuildRateFullCost(g: Game, shipment: PendingShipment): number | function openGuildRate(g: Game, shipment: PendingShipment): boolean { const fullCost = ordinaryGuildRateFullCost(g, shipment); if (fullCost === null) return false; + const cunning = shipment.guildNexusEvent + ? validateGuildCunningShipment(g, shipment.player, shipment.guildNexusEvent, 'reserve', shipment) + : null; // A validated older Guild-stop declaration may lack this newer turn stamp. shipment.turn ??= g.turn; const event = crypto.randomUUID(); shipment.guildRateEvent = event; + if (cunning) { + cunning.shipment!.frame = JSON.stringify(shipment); + saveGuildCunning(g, cunning); + } g.pendingShipment = shipment; g.response = { kind: 'guildRate', @@ -26528,7 +26534,7 @@ function validateGuildRateResponse(g: Game, response: ResponseWindow) { const p = shipment && getPlayer(g, shipment.player); const fullCost = shipment ? ordinaryGuildRateFullCost(g, shipment) : null; requireRule(response.kind === 'guildRate' && guild && p && shipment && - g.status === 'playing' && g.phase === 5 && g.active === p.id && !p.shipped && + g.status === 'playing' && g.phase === 5 && g.active === p.id && shipmentAvailable(g, p) && response.owner === guild.id && typeof shipment.guildRateEvent === 'string' && shipment.guildRateEvent.length > 0 && response.guildRateEvent === shipment.guildRateEvent && @@ -26556,24 +26562,35 @@ function guildRateIntegrity(g: Game) { function finishGuildRateResponse(g: Game, response: ResponseWindow, canceled: boolean) { const { shipment, fullCost } = validateGuildRateResponse(g, response); const shipper = getPlayer(g, shipment.player); + const cunning = shipment.guildNexusEvent + ? validateGuildCunningShipment(g, shipper.id, shipment.guildNexusEvent, 'reserve', shipment) + : null; g.pendingShipment = null; delete shipment.guildRateEvent; - if (!canceled) { - commitShipment(g, shipment); - return; + if (canceled) { + g.guildRateBlocked = { turn: g.turn, player: shipper.id }; + const bounds = shipmentPaymentBounds(fullCost, shipper.spice, + aidFor(g, shipper)?.amount ?? 0); + if (shipment.allyPayment < bounds.minimum || shipment.allyPayment > bounds.maximum) { + if (cunning) { + delete cunning.shipment; + saveGuildCunning(g, cunning); + } + log(g, `Karama removed Guild's half-price rate from ${shipper.name}'s declaration. Its original approved payment split cannot fund the full price, so no spice or forces were spent and shipment remains available at full price this turn.`); + return; + } + shipment.cost = fullCost; + checkShipmentIncomeFunding(g, shipper, fullCost, shipment.allyPayment); } - g.guildRateBlocked = { turn: g.turn, player: shipper.id }; - const bounds = shipmentPaymentBounds(fullCost, shipper.spice, - aidFor(g, shipper)?.amount ?? 0); - if (shipment.allyPayment < bounds.minimum || shipment.allyPayment > bounds.maximum) { - log(g, `Karama removed Guild's half-price rate from ${shipper.name}'s declaration. Its original approved payment split cannot fund the full price, so no spice or forces were spent and shipment remains available at full price this turn.`); - return; + if (cunning) { + cunning.shipment!.frame = JSON.stringify(shipment); + saveGuildCunning(g, cunning); } - shipment.cost = fullCost; - checkShipmentIncomeFunding(g, shipper, fullCost, shipment.allyPayment); commitShipment(g, shipment); - delete g.guildRateBlocked; - log(g, `Karama removed Guild's half-price rate. ${shipper.name} paid the full ${fullCost} spice for the unchanged shipment.`); + if (canceled) { + delete g.guildRateBlocked; + log(g, `Karama removed Guild's half-price rate. ${shipper.name} paid the full ${fullCost} spice for the unchanged shipment.`); + } } function guildTransportSignature(intent: PendingGuildTransport): string { return JSON.stringify([intent.event, intent.turn, intent.player, intent.fromReserves, @@ -26618,7 +26635,7 @@ function validateGuildTransportRateResponse(g: Game, response: ResponseWindow) { ? ordinaryGuildTransportFullCost(g, shipper, intent) : null; requireRule(response.kind === 'guildRate' && intent && guild && shipper && g.status === 'playing' && g.phase === 5 && g.active === shipper.id && - !shipper.shipped && intent.turn === g.turn && + shipmentAvailable(g, shipper) && intent.turn === g.turn && typeof intent.event === 'string' && intent.event.length > 0 && response.owner === guild.id && response.guildRateEvent === intent.event && typeof intent.fromReserves === 'boolean' && @@ -27165,7 +27182,10 @@ function validateGuildCunningShipment(g: Game, owner: string, event: string, kin const record = currentGuildCunning(g,owner); requireRule(record?.stage === 'secondShipment' && record.receipt.event === event && record.shipment?.stage === 'pending' && record.shipment.kind === kind && record.shipment.frame === JSON.stringify(frame), 'Guild Cunning has lost its original second shipment.'); - return record; + // The validated kind excludes a declined (nonphysical) second shipment. + return record as typeof record & { + shipment?: Exclude, {kind: 'declined'}>; + }; } function finishGuildCunningShipment(g: Game, owner: string, kind: 'reserve'|'guild'|'homeworld', frame: unknown, outcome:'complete'|'stopped' = 'complete') { const record = currentGuildCunning(g,owner); @@ -27227,8 +27247,11 @@ function nexusGuildCunningIntegrity(g: Game) { requireRule(g.pendingShipment?.guildNexusEvent === active.receipt.event, 'Guild Cunning lost its pending reserve shipment.'); validateGuildCunningShipment(g,owner.id,active.receipt.event,'reserve',g.pendingShipment); const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'guildShipment'); - requireRule(decisions.length > 0,'Guild Cunning lost its shipment interception decision.'); + const rates = savedNoFieldResponses(g).filter(response => response.kind === 'guildRate'); + requireRule(decisions.length > 0 || rates.length === 1, + 'Guild Cunning lost its shipment interception or rate response.'); 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); diff --git a/game/reference.ts b/game/reference.ts index 7ee39f17..833ed9c6 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -142,10 +142,10 @@ const phaseDetails = [ [ 'At the start of this phase, resolve the Atreides spice foresight response. If allowed, Atreides privately inspects the next card, including when using double spice blow.', 'Select a destination and sector. Ship from reserves before making a force move.', - 'A classic Guild or reciprocal ally’s discounted ordinary reserve shipment or eligible cross-planet transport waits for a Karama response before paying. Guild’s own return to reserves waits too. Canceling the rate uses normal destination pricing for the same physical forces and approved payment split; a return costs one spice per force. If the split or exact group cannot complete, the declaration returns unused and a replacement this turn uses full price. Full-price return and unaffordable withdrawal are documented table interpretations, not separate publisher examples. Guild income, purchased Karama rates, transport permission and optional modules remain distinct.', + 'Native Guild or its reciprocal ally’s discounted ordinary reserve shipment or eligible cross-planet transport waits for a Karama response before paying, including expansion/Nexus tables without Homeworlds, Skills, Discovery, Tech or Strongholds. Guild’s own return waits too. The same physical group and approved split must cover full price after cancellation; otherwise the declaration returns unused and a replacement this turn uses full price. Cunning retains its separate second allowance and original shipped history. Full-price return and unaffordable withdrawal remain provisional policies; income, independent purchased rates and transport permission are distinct.', 'For movement, select the source forces. Enable Combine sectors to include several groups from the same territory.', 'A Fremen move using the two-territory advantage has a Karama response. Cancellation leaves the forces in place and limits this move to one territory; city ornithopters and a later extra move keep their normal rules.', - 'Guild transport can move one exact physical group across the planet or return it to reserves. In classic games its normal half-price rate can be canceled separately from its transport permission. Ordinary movement is checked against range, storm and stronghold occupancy.', + 'Guild transport moves one exact physical group across the planet or returns it to reserves. Native half-price cancellation is separate from transport permission, including supported expansion/Nexus tables and Cunning’s second shipment. Ordinary movement still checks range, storm and stronghold occupancy.', 'You may pass through an ally’s territory on a legal route that ends elsewhere. Ordinary physical reserve shipments in both modes may enter allied territory before movement, then depart during that turn. Storm and stronghold capacity remain binding. Basic visibly labels its provisional no-exit policy: excess new visitors still present go to Tanks. Advanced follows supplied p15: all the ending player’s shared forces go to Tanks, including old forces, subject to advisor/Polar/Ecaz exemptions. Neither grants an extra move. Concealed No-Fields and optional arrivals remain separate.', 'Ecaz and its reciprocal ally may share territories and count as one faction toward stronghold capacity, without extra movement or storm/Homeworld exceptions. Their three-joint-stronghold victory and desert allocation are connected. Fremen/Ecaz cooccupation permits the Fremen special win in Sietch Tabr in Basic; supplied Advanced p32 permits both Tabr and Habbanya Sietch. Other victory conditions and exceptional combined interactions remain separate.', 'A restored pending physical shipment is checked again before forces or spice are committed. If its saved turn, force allocation, destination, price or authorized contribution no longer matches the table, it is rejected without changing the saved declaration.', @@ -1431,7 +1431,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Documentation', status: 'Partial', detail: - 'Source-bound effects, copies and Duke guides remain. Native own-arrival continuation explicitly records existing child-first/live-token order as provisional, preserving actual controls, original tariffs and unsupported paired/module guards. Guild native rate-Karama expansion/Nexus admission is separate unfinished work, not a printed prohibition.', + 'Source-bound effects, copies and Duke guides remain. Native own-arrival continuation explicitly records child-first/live-token order as provisional, preserving actual controls and unsupported paired/module guards. Separate native rate cancellation now admits expansion/Nexus reserve/cross/return and Cunning receipts; Homeworld/Skills/Discovery/Tech/Stronghold rate compositions remain unfinished.', evidence: ['docs/ECAZ_AMBASSADORS_RULES.md', 'docs/DEFERRED_SHIPMENT_ARRIVAL.md', 'docs/BOT_ARRIVAL_PREFLIGHT.md'], }, { @@ -2556,7 +2556,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'If eligible to bid, use the card to take the current auction card without paying spice. A full hand cannot bypass the bidding limit.', 'Holding Karama also permits bids beyond your available spice. A winner can choose to spend it; an outbid player keeps it.', 'Use Cancel with Karama during a power response. Supported responses include Voice, prescience, Atreides auction and spice peeks, spiritual advisors, Emperor gifts and revival, faction income, Harkonnen bonus cards and allied traitors, plus Fremen storm and worm powers, elite combat strength and Fremen free spice support.', - 'In classic-faction games without optional modules, cancel Guild’s half-price rate before an ordinary reserve shipment, eligible cross-planet transport or Guild return moves any spice or forces. Full destination price applies to the same physical group if the original approved payment split can afford it; otherwise the declaration returns unused and its replacement is full price this turn. The full-price return tariff and unaffordable withdrawal are documented table policies, not printed refund examples. Guild income, permission to cross/return and the independent rate purchased by Karama remain separate.', + 'Native Guild half-price cancellation covers ordinary reserve shipment, eligible cross-planet transport and Guild return before payment or arrival, including expansion/Nexus tables without Homeworlds, Skills, Discovery, Tech or Strongholds. Cunning’s second reserve/cross shipment retains its original allowance and history. Full price applies to the same group and approved split if affordable; otherwise the uncommitted attempt returns and its replacement is full price this turn. The return tariff and unaffordable outcome remain documented table policies. Income, transport permission and independent Karama prices are separate.', 'During shipment, use Guild rates for the active player before they ship. They choose and pay for their shipment at the rounded half rate, paid to the bank. You may provide this benefit for another player; it expires if they move or finish without shipping.', 'Advanced Bene Gesserit may spend Worthless cards as Karama. Each conversion opens a separate cancellation response; the Worthless card is discarded even if the power is canceled. Normal Karama cards resolve without that conversion response.', 'A pending Worthless purchase keeps space for its original auction card. Gifts and hand exchanges must leave enough room for every committed incoming card. Refreshing restores the same auction, unused shipment opportunity or original canceled power.', @@ -4653,6 +4653,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Native Richese Cunning uses the original signed two-No-Field shipment in supported single/paired E2 or selected mixed Ix+CHOAM native Nexus tables with classics, optional Skills/Homeworlds/Discovery/Tech/Advanced Strongholds. Select two distinct unused physical tokens: one reveals immediately into reserve-capped forces, one remains concealed; pay one marker invoice and spend the Nexus. The previous shipment token cannot be either choice, and the concealed one becomes last-used. Native/Guild response and actual payer/counter sources remain; a nested destination must be revealed. Prevention keeps physical tokens and unpaid spice, after any printed card cost. Sandtrout, arbitrary companions, shared allocation and other pending combinations retain their guards.', 'Richese Betrayal has a separate opt-in auction preview. Before payment, an unallied rival may spend its physical Richese Nexus card to discard a public self-cache win without any purchase debit, or redirect another buyer’s cache/Black Market payment to the bank while preserving delivery and earned bonus. Every publicly possible holder acknowledges the same opportunity; private Use eligibility cannot determine public timing. Normal hidden self-purchases and private special-Karama acquisitions are not connected. See the dedicated guide for its existing local runtime/browser evidence and remaining release gates; that evidence does not verify Nexus Kull.', 'Guild Cunning is declared when finishing your ordinary Shipment and Movement turn. It spends the Nexus and opens one Karama response. Allowance grants a second native shipment at normal Guild prices, including supported cross-shipment, return and Homeworld routes. Cancellation leaves the completed first turn intact. Only an unused Hajr move can follow the second shipment; no ordinary movement or third move is granted. The physical Ornithopter card combination remains unavailable. The separate guild-betrayal profile admits only original native shipment producers and explicitly blocks this second-shipment offer before Nexus spending; Cunning outside that profile is unchanged.', + 'In supported native expansion/Nexus tables without other physical modules, Cunning’s second reserve/cross shipment has the normal prepayment rate-Karama window. Its original shipped flag stays true. Allowance preserves rounded half price; cancellation pays full price once or returns the uncommitted second attempt without losing that allowance. A replacement stays full price. The first shipment and ordinary movement never reopen.', 'With Guild absent, its Secret Ally is an explicit optional source for one ordinary shipment at Guild prices. It supports physical reserve shipment, cross-shipment and return to the holder’s own reserves, including the native-Homeworld return when Homeworlds are enabled, plus half-price independently legal world-to-world shipment. Fremen may retain free reinforcement or choose the paid card route beyond its normal range, outside the storm. Native faction, typed sources and normal movement stay unchanged. Arbitrary foreign-world transport and concealed No-Fields are not granted.', 'Borrowed Guild/Richese tariffs do not change native faction or physical reserve origin. Non-Guild/non-Fremen off-planet arrivals can accrue Heighliners once for collection at the original phase end. A paid borrowed Guild route from Fremen southern reserves is still on-planet and does not trigger it; stopped and native-Guild-only shipments likewise remain excluded. The existing Emperor bank-auction purchase retains the actual buyer wallet and creates no industry activity.', 'Guild Betrayal has a separate fresh classic/native-Guild development preview. Before a fully funded positive shipment settles, an unallied holder may spend the physical Guild Nexus to take the full original fee, including their own pre-funded payment, instead of Guild, bank or occupied Junction income. Original donor escrow is consumed once and original physical delivery is unchanged. Neutral public acknowledgements reveal no fee/source; only private eligibility exposes Use. Zero/stopped shipments create no award or Nexus cost. See the dedicated guide; new verification remains Planned and ordinary Homeworld occupation/rounding questions stay local.', @@ -4661,11 +4662,11 @@ export const RULE_TOPICS: RuleTopic[] = [ ], related: ['ecaz-modules', 'spice-blow', 'alliance-funding', 'choam-worthless', 'choam-kull', 'nexus-choam-kull', 'nexus-richese-betrayal', 'nexus-guild-betrayal', 'nexus-ixian-replacement', 'nexus-ixian-betrayal'], checklist: [ - {area:"Implementation",status:'Partial',detail:"Shared Traitor snapshots now account for permanent Moritani markers outside reserve/hands/Face Dancers, retaining existing Loyalty exclusion. Exchange, Face Dancer, Betrayal and Rihani consumers bind their own source zone; absent-zone grammar stays unchanged. Historical replacements may be legally returned and drawn again. Existing Cunning timing, costs, custody, actor/parent and roster gates remain; full combinations stay open.",evidence:['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts', 'game/nexus-fremen-protection.ts', 'game/admin-seat-ai-shape.ts', 'game/richese-cancellation.ts', 'game/bot-mobility.ts', 'game/planetologist-movement.ts', 'game/revival.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally.ts', 'game/revival-resume.ts', 'game/revival-cancellation.ts', 'game/homeworld-revival-deployment.ts', 'game/homeworld-revival-return.ts', "game/nexus-emperor-purchase.ts", "game/auction-continuation-quote.ts", "game/nexus-emperor-purchase.ts", "game/ambassador-phase.ts", "game/ambassador-resume.ts", "game/battle-aftermath-quote.ts", "game/ecaz-duke-acquisition.ts", "game/ecaz-duke-revival.ts", "game/nexus-ecaz-duke-options.ts", "game/nexus-play-boundary.ts"]}, - {area:"Player controls",status:'Partial',detail:"Actual390px Secret Ally draws two, returns historical Hasimir6 plus Caid3, keeps Esmar3 and restores the original Mori placement. Passing it reaches the next Storm. A later genuine Esmar3 retirement draws one physical Hasimir again while older exchange history stays unchanged. Hero replacement inspector is private; no overflow/browser errors. Earlier Cunning/purchase controls remain bounded evidence, not full acceptance.",evidence:['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx', 'components/nexus-fremen-revival.tsx','components/nexus-emperor-payment.tsx', 'components/nexus-emperor-secret-ally.tsx', "components/nexus-emperor-seller-purchase.tsx", "components/nexus-emperor-ambassador-purchase.tsx", "components/richese-special-karama.tsx"]}, - {area:"AI",status:'Partial',detail:"All four profiles legally complete the actual augmented-hand return, retaining the original parent; in this position each keeps Hasimir rather than following the scripted old-card return. All four also Ready through original owned Mentat initialization before automatic Hero draw. Existing other module adapters remain; strategic AI and calibration are deferred.",evidence:['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally-options.ts']}, - {area:"Documentation",status:'Partial',detail:"Permanent victim markers, live Traitor custody and historical replacement references are distinct. Hero is a Traitor replacement, not a leader disc or native faction target. Sources/receipts keep their own optional removed zone; old absent formats and existing Loyalty exclusion remain. No past-save authenticity, new numeric ruling or profile/public gate widening is claimed.",evidence:['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/NEXUS_GUILD_RULES.md', "docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md", "docs/RULE_DECISIONS.md", "docs/RICHESE_AMBASSADOR_RULES.md", "docs/RICHESE_AMBASSADOR_IMPLEMENTATION.md", "docs/RICHESE_ACQUISITION_RULES.md", "docs/DUKE_VIDAL_RULES.md", "docs/ECAZ_DUKE_ACQUISITION.md", "docs/ECAZ_REVIVAL_RULES.md"]}, - {area:"Verification",status:'Partial',detail:"Current affected15-file134/134, eleven original API/policy cases and three actual390px journeys pass: physical Hero/next Storm, historical replacement return/resumed placement, then the same physical draw after a later second marker. Adjacent old fixtures now consume original borrowed Voice before native Prescience/plans. Earlier Cunning/native evidence stays historical; no saved/deployed or full-module assurance claim.",evidence:['tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts', 'tests/nexus-fremen-protection.test.ts', 'tests/fixture-nexus-fremen-protection.ts', 'tests/nexus-fremen-protection-runtime.test.ts', 'tests/fixture-stronghold-factions.ts', 'tests/fixture-nexus-advisor-fresh.ts', 'tests/nexus-advisor-fresh-runtime.test.ts', 'tests/fixture-nexus-advisor-storm.ts', 'tests/nexus-advisor-storm-runtime.test.ts', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'tests/nexus-fremen-free-quota.test.ts', 'tests/fixture-nexus-fremen-free-quota.ts', 'tests/nexus-fremen-free-quota-runtime.test.ts', 'tests/nexus-fremen-revival.test.ts','tests/nexus-emperor-payment.test.ts','tests/fixture-nexus-emperor-payment.ts','tests/nexus-emperor-payment-runtime.test.ts','docs/NEXUS_EMPEROR_RULES.md', 'tests/nexus-emperor-extra-source.test.ts', 'tests/nexus-emperor-extra-continuation.test.ts', 'tests/fixture-nexus-emperor-extra.ts', 'tests/nexus-emperor-extra-runtime.test.ts', 'tests/fixture-single-nexus-e1.ts', 'docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md', "tests/fixture-nexus-emperor-seller.ts", "tests/nexus-emperor-seller-purchase.test.ts", "tests/nexus-emperor-seller-runtime.test.ts", "tests/fixture-nexus-emperor-ambassador.ts", "tests/nexus-emperor-ambassador-purchase.test.ts", "tests/nexus-emperor-ambassador-runtime.test.ts", "tests/fixture-nexus-emperor-special.ts", "tests/nexus-emperor-special-purchase.test.ts", "tests/nexus-emperor-special-runtime.test.ts", "tests/karama-intents.test.ts", "tests/fixture-nexus-emperor-bank.ts", "tests/nexus-emperor-bank-profile.test.ts", "tests/nexus-emperor-bank-runtime.test.ts", "tests/duke-captured-custody.test.ts", "tests/nexus-ecaz-duke-captured.test.ts", "tests/fixture-nexus-duke-capture.ts", "tests/nexus-duke-capture-runtime.test.ts", "tests/nexus-duke-timing-runtime.test.ts"]}, + {area:"Implementation",status:'Partial',detail:"Physical Traitor roles, permanent markers, optional-zone receipts and legal recycling remain integrated. Native expansion/Nexus rate cancellation now covers reserve/cross/return. Cunning reserve receipts bind the temporary rate event and settled price; unaffordable release retains only the second allowance. Both validators reuse source-aware shipment availability instead of clearing original shipped history. Remaining physical-module rate compositions and full acceptance stay open.",evidence:['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts', 'game/nexus-fremen-protection.ts', 'game/admin-seat-ai-shape.ts', 'game/richese-cancellation.ts', 'game/bot-mobility.ts', 'game/planetologist-movement.ts', 'game/revival.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally.ts', 'game/revival-resume.ts', 'game/revival-cancellation.ts', 'game/homeworld-revival-deployment.ts', 'game/homeworld-revival-return.ts', "game/nexus-emperor-purchase.ts", "game/auction-continuation-quote.ts", "game/nexus-emperor-purchase.ts", "game/ambassador-phase.ts", "game/ambassador-resume.ts", "game/battle-aftermath-quote.ts", "game/ecaz-duke-acquisition.ts", "game/ecaz-duke-revival.ts", "game/nexus-ecaz-duke-options.ts", "game/nexus-play-boundary.ts"]}, + {area:"Player controls",status:'Partial',detail:"Earlier physical Hero and historical Traitor-return/recycling controls retain source-bound proof. Three current390px journeys exercise native full-price cancellation, unaffordable withdrawal/full-price replacement and Cunning reserve repricing through Finish Guild turn. Original groups, paid first shipment and no ordinary extra movement remain; no overflow/browser errors. The Cunning scene uses the explicitly controlled existing fixture, not the action-only native producer.",evidence:['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx', 'components/nexus-fremen-revival.tsx','components/nexus-emperor-payment.tsx', 'components/nexus-emperor-secret-ally.tsx', "components/nexus-emperor-seller-purchase.tsx", "components/nexus-emperor-ambassador-purchase.tsx", "components/richese-special-karama.tsx"]}, + {area:"AI",status:'Partial',detail:"All four actual owner policies legally pass the new native expansion/Nexus rate response, pay rounded two for three and reach the original Ambassador offer; none cancels in that position. Existing Cunning bots and earlier Hero/Traitor-return participation remain covered. Human cancellation/retained-fighter branches are distinct proof. Strategy and calibration remain deferred.",evidence:['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally-options.ts']}, + {area:"Documentation",status:'Partial',detail:"Physical Hero, permanent victims and historical references stay distinct. Native rate admission and Cunning receipt transitions are documented separately from income and transport permission. Existing full-price return and unaffordable replacement policies remain provisional; no new publisher priority or numerical ruling. Homeworlds, Skills, Discovery, Tech and Stronghold rate combinations and public gates remain separate.",evidence:['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/NEXUS_GUILD_RULES.md', "docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md", "docs/RULE_DECISIONS.md", "docs/RICHESE_AMBASSADOR_RULES.md", "docs/RICHESE_AMBASSADOR_IMPLEMENTATION.md", "docs/RICHESE_ACQUISITION_RULES.md", "docs/DUKE_VIDAL_RULES.md", "docs/ECAZ_DUKE_ACQUISITION.md", "docs/ECAZ_REVIVAL_RULES.md"]}, + {area:"Verification",status:'Partial',detail:"Current affected nine-file101/101 and five action-only original API/policy paths pass. Six controlled Cunning reserve/cross cases cover allow/cancel/unaffordable replacement without reopening the first turn. Three actual390px journeys prove full price, no-debt withdrawal/replacement and Cunning turn completion. Earlier134/134 Hero/Traitor lifecycle evidence remains historical. No saved/deployed or full-module assurance claim.",evidence:['tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/native-guild-rate-karama.test.ts', 'tests/nexus-guild-cunning-engine.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts', 'tests/nexus-fremen-protection.test.ts', 'tests/fixture-nexus-fremen-protection.ts', 'tests/nexus-fremen-protection-runtime.test.ts', 'tests/fixture-stronghold-factions.ts', 'tests/fixture-nexus-advisor-fresh.ts', 'tests/nexus-advisor-fresh-runtime.test.ts', 'tests/fixture-nexus-advisor-storm.ts', 'tests/nexus-advisor-storm-runtime.test.ts', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'tests/nexus-fremen-free-quota.test.ts', 'tests/fixture-nexus-fremen-free-quota.ts', 'tests/nexus-fremen-free-quota-runtime.test.ts', 'tests/nexus-fremen-revival.test.ts','tests/nexus-emperor-payment.test.ts','tests/fixture-nexus-emperor-payment.ts','tests/nexus-emperor-payment-runtime.test.ts','docs/NEXUS_EMPEROR_RULES.md', 'tests/nexus-emperor-extra-source.test.ts', 'tests/nexus-emperor-extra-continuation.test.ts', 'tests/fixture-nexus-emperor-extra.ts', 'tests/nexus-emperor-extra-runtime.test.ts', 'tests/fixture-single-nexus-e1.ts', 'docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md', "tests/fixture-nexus-emperor-seller.ts", "tests/nexus-emperor-seller-purchase.test.ts", "tests/nexus-emperor-seller-runtime.test.ts", "tests/fixture-nexus-emperor-ambassador.ts", "tests/nexus-emperor-ambassador-purchase.test.ts", "tests/nexus-emperor-ambassador-runtime.test.ts", "tests/fixture-nexus-emperor-special.ts", "tests/nexus-emperor-special-purchase.test.ts", "tests/nexus-emperor-special-runtime.test.ts", "tests/karama-intents.test.ts", "tests/fixture-nexus-emperor-bank.ts", "tests/nexus-emperor-bank-profile.test.ts", "tests/nexus-emperor-bank-runtime.test.ts", "tests/duke-captured-custody.test.ts", "tests/nexus-ecaz-duke-captured.test.ts", "tests/fixture-nexus-duke-capture.ts", "tests/nexus-duke-capture-runtime.test.ts", "tests/nexus-duke-timing-runtime.test.ts"]}, ], }, { diff --git a/tests/fixture-guild-ambassador-income-arrival.ts b/tests/fixture-guild-ambassador-income-arrival.ts index 391a3688..fea2650a 100644 --- a/tests/fixture-guild-ambassador-income-arrival.ts +++ b/tests/fixture-guild-ambassador-income-arrival.ts @@ -18,9 +18,9 @@ import { quietPlan } from './fixture-nexus-duke-capture'; * is ordered for land blows on turn one and an actual Nexus on turn two. * No hand, wallet, army, leader, phase, alliance or response is staged. * - * The expansion/Nexus admission guard deliberately excludes the classic-only - * Guild rate-cancellation window. Actual native half price and its separate - * income/counter window are retained, not widened by this fixture. */ + * A one-force stronghold arrival has no rounded native-rate discount to + * cancel (half/full both cost one). Larger native shipments retain the + * separate prepayment rate window before this fixture's income path. */ export function createGuildAmbassadorIncomeArrival() { const setup = createPairedE3NexusSetup({ roster: ['ecaz', 'moritani', 'guild'], advanced: true, skills: false, homeworlds: false, discovery: false, diff --git a/tests/native-guild-rate-karama.test.ts b/tests/native-guild-rate-karama.test.ts new file mode 100644 index 00000000..c598fd08 --- /dev/null +++ b/tests/native-guild-rate-karama.test.ts @@ -0,0 +1,131 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { + createGuildAmbassadorIncomeArrival, allowGuildAmbassadorIncomeArrival, + completeGuildAmbassadorIncomeArrival, +} from './fixture-guild-ambassador-income-arrival'; +import { + advancePairedE3Nexus, nextPairedE3NexusStep, pairedE3NexusClean, + quietPairedE3NexusChoice, +} from './fixture-paired-e3-nexus-modules'; + +const player = (g: Game, id: string) => g.players.find(p => p.id === id)!; +const act = (g: Game, id: string, action: Action) => applyAction(structuredClone(g), id, action); +type NativeFixture = ReturnType; +function declare(f: NativeFixture, before: Game, route: 'reserve' | 'transport', amount: number) { + const action: Action = route === 'transport' ? { ...f.action, amount } : { + type: 'ship', territory: f.destination, sector: f.sector, amount, elite: 0, allyPayment: 0, + }; + let g = act(before, f.entrant, action); + if (g.decision?.kind === 'guildShipment') { + assert.equal(route, 'reserve'); + assert.deepEqual(player(g, f.entrant).forces, player(before, f.entrant).forces); + g = act(g, f.guild, { type: 'decision', allow: true }); + } + return g; +} +function allowRate(state: Game) { + let g = state; + for (let n = 0; g.response?.kind === 'guildRate' && n < 20; n++) { + const step = nextPairedE3NexusStep(g); assert.ok(step); + assert.equal(step.action.type, 'passResponse'); + g = act(g, step.actor, step.action); + } + assert.notEqual(g.response?.kind, 'guildRate'); + return g; +} +function entryAfterIncome(g: Game) { + return g.response ? allowGuildAmbassadorIncomeArrival(g).game : g; +} +function assertPhysical(f: NativeFixture, before: Game, after: Game, route: 'reserve' | 'transport', amount: number, cost: number) { + const p = player(after, f.entrant), old = player(before, f.entrant); + assert.equal(p.spice, old.spice - cost); + assert.equal(p.reserves, old.reserves - (route === 'reserve' ? amount : 0)); + assert.equal(p.forces[f.source], old.forces[f.source] - (route === 'transport' ? amount : 0)); + assert.equal(p.forces[f.target], (old.forces[f.target] ?? 0) + amount); + assert.equal(p.shipped, true); + assert.equal(player(after, f.guild).spice, player(before, f.guild).spice + cost); + assert.equal(after.pendingAmbassador?.entrant, f.entrant); + assert.ok(!after.pendingArrivalReaction); +} + +for (const route of ['reserve', 'transport'] as const) { + void test(`original expansion/Nexus ${route}: native discount waits before payment; cancellation charges the unchanged full group once`, () => { + const f = createGuildAmbassadorIncomeArrival(), before = f.beforeAction; + const offered = declare(f, before, route, 3); + assert.equal(offered.response?.kind, 'guildRate'); + assert.equal(player(offered, f.entrant).spice, player(before, f.entrant).spice); + assert.equal(player(offered, f.entrant).reserves, player(before, f.entrant).reserves); + assert.deepEqual(player(offered, f.entrant).forces, player(before, f.entrant).forces); + assert.ok(!offered.pendingAmbassador); + assert.ok(viewGame(offered, f.ecaz).responseControls?.cancelCards.includes(f.karama)); + const canceled = act(offered, f.ecaz, { type: 'card', mode: 'cancel', card: f.karama }); + const entry = entryAfterIncome(canceled); + assertPhysical(f, before, entry, route, 3, 3); + assert.equal(entry.discard.filter(c => c.id === f.karama).length, 1); + const completed = completeGuildAmbassadorIncomeArrival(entry); + const next = act(completed.after, completed.continuation.actor, completed.continuation.action); + assert.notEqual(next.active, f.entrant); + assert.equal(player(next, f.entrant).spice, player(before, f.entrant).spice - 3); + assert.deepEqual(player(next, f.entrant).forces, player(entry, f.entrant).forces); + }); + + void test(`original expansion/Nexus ${route}: allowing the rate retains rounded discount and its separate income counter`, () => { + const f = createGuildAmbassadorIncomeArrival(), before = f.beforeAction; + const offered = declare(f, before, route, 3); + assert.equal(offered.response?.kind, 'guildRate'); + const paid = allowRate(offered); + assert.equal(paid.response?.kind, 'guildIncome'); + assert.equal(player(paid, f.entrant).spice, player(before, f.entrant).spice - 2); + assert.equal(player(paid, f.guild).spice, player(before, f.guild).spice); + const entry = entryAfterIncome(paid); + assertPhysical(f, before, entry, route, 3, 2); + assert.ok(player(entry, f.ecaz).hand.some(c => c.id === f.karama)); + }); + + void test(`original expansion/Nexus ${route}: unaffordable cancellation spends only Karama and permits a full-price replacement`, () => { + const f = createGuildAmbassadorIncomeArrival(); + const before = act(f.beforeAction, f.entrant, { type: 'bribe', target: f.ecaz, + amount: player(f.beforeAction, f.entrant).spice - 3 }); + assert.equal(player(before, f.entrant).spice, 3); + const offered = declare(f, before, route, 6); + assert.equal(offered.response?.kind, 'guildRate'); + const released = act(offered, f.ecaz, { type: 'card', mode: 'cancel', card: f.karama }); + assert.equal(player(released, f.entrant).spice, 3); + assert.deepEqual(player(released, f.entrant).forces, player(before, f.entrant).forces); + assert.equal(player(released, f.entrant).reserves, player(before, f.entrant).reserves); + assert.equal(player(released, f.entrant).shipped, false); + assert.equal(player(released, f.guild).spice, player(before, f.guild).spice); + assert.ok(!released.pendingShipment); + assert.ok(!released.pendingGuildTransport); + assert.ok(!released.pendingAmbassador); + assert.equal(released.discard.filter(c => c.id === f.karama).length, 1); + assert.equal(viewGame(released, f.entrant).guildRateCanceled, true); + const replacement = declare(f, released, route, 3); + assert.notEqual(replacement.response?.kind, 'guildRate'); + const entry = entryAfterIncome(replacement); + assertPhysical(f, before, entry, route, 3, 3); + }); +} + +void test('original expansion/Nexus Guild return has its own prepayment rate window and full-price physical return', () => { + const f = createGuildAmbassadorIncomeArrival(); + const before = advancePairedE3Nexus(f.beforeAction, + g => g.phase === 5 && g.active === f.guild && pairedE3NexusClean(g), + [], quietPairedE3NexusChoice); + const p = player(before, f.guild); + const source = Object.keys(p.forces).find(key => p.forces[key] >= 3)!; + assert.ok(source); + const offered = act(before, f.guild, { type: 'guildShip', from: source, + territory: 'reserves', amount: 3, elite: 0, allyPayment: 0 }); + assert.equal(offered.response?.kind, 'guildRate'); + assert.deepEqual(player(offered, f.guild).forces, p.forces); + const done = act(offered, f.ecaz, { type: 'card', mode: 'cancel', card: f.karama }); + assert.equal(player(done, f.guild).spice, p.spice - 3); + assert.equal(player(done, f.guild).forces[source], p.forces[source] - 3); + assert.equal(player(done, f.guild).reserves, p.reserves + 3); + assert.equal(player(done, f.guild).shipped, true); + assert.ok(!done.pendingGuildTransport); + assert.ok(!done.pendingAmbassador); +}); diff --git a/tests/nexus-guild-cunning-engine.test.ts b/tests/nexus-guild-cunning-engine.test.ts index fa5f0065..f40fc3da 100644 --- a/tests/nexus-guild-cunning-engine.test.ts +++ b/tests/nexus-guild-cunning-engine.test.ts @@ -480,3 +480,59 @@ void test('Advanced Guild Cunning finishes within a genuinely granted out-of-ord assert.equal(new Set(g.movementRemaining).size, g.movementRemaining!.length); stable(g); }); + +for (const route of ['reserve', 'transport'] as const) { + for (const outcome of ['allow', 'cancel', 'unaffordable'] as const) { + void test(`Guild Cunning ${route} preserves its second allowance through native rate ${outcome}`, () => { + // Existing controlled final-setup fixture; distinct from the original + // action-only expansion/Nexus producer in native-guild-rate-karama. + const f = nexusGuildCunningFixture({ advanced: true, karama: true }); + let g = originalGuildCunningTurn(f, false); + g = nexusGuildCunningAllow( + applyAction(g, f.owner, nexusGuildCunningRequest(f, g)), + ); + if (outcome === 'unaffordable') + g = applyAction(g, f.owner, { type: 'bribe', target: f.observer, + amount: g.players[0].spice - 2 }); + const before = structuredClone(g.players[0]); + const shipment: Action = route === 'reserve' + ? { type: 'ship', territory: 'carthag', sector: 11, amount: 3 } + : { type: 'guildShip', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 3 }; + g = applyAction(g, f.owner, shipment); + if (route === 'reserve') { + assert.equal(g.decision?.kind, 'guildShipment'); + g = applyAction(g, f.owner, { type: 'decision', allow: true }); + } + assert.equal(g.response?.kind, 'guildRate'); + assert.equal(g.players[0].shipped, true); + assert.equal(g.nexusGuildCunningLast?.stage, 'secondShipment'); + assert.equal(g.players[0].spice, before.spice); + assert.equal(g.players[0].reserves, before.reserves); + assert.deepEqual(g.players[0].forces, before.forces); + g = outcome === 'allow' ? nexusGuildCunningAllow(g) + : applyAction(g, f.target, { type: 'card', mode: 'cancel', card: f.karama! }); + let amount = 3, cost = outcome === 'allow' ? 2 : 3; + if (outcome === 'unaffordable') { + assert.equal(g.nexusGuildCunningLast?.stage, 'secondShipment'); + assert.ok(!g.nexusGuildCunningHistory!.at(-1)!.shipment); + assert.equal(g.players[0].spice, before.spice); + assert.equal(g.players[0].reserves, before.reserves); + assert.deepEqual(g.players[0].forces, before.forces); + assert.equal(viewGame(g, f.owner).guildRateCanceled, true); + g = settleGuildCunningShipment(applyAction(g, f.owner, { ...shipment, amount: 2 })); + amount = cost = 2; + } + assert.equal(g.nexusGuildCunningLast?.stage, 'extraMove'); + assert.equal(g.nexusGuildCunningHistory!.at(-1)!.shipment!.stage, 'complete'); + assert.equal(g.players[0].spice, before.spice - cost); + assert.equal(g.players[0].reserves, before.reserves - (route === 'reserve' ? amount : 0)); + assert.equal(g.players[0].forces['arrakeen:10'], + before.forces['arrakeen:10'] - (route === 'transport' ? amount : 0)); + assert.equal(g.players[0].forces['carthag:11'], amount); + assert.equal(g.discard.filter(card => card.id === f.karama).length, outcome === 'allow' ? 0 : 1); + g = applyAction(g, f.owner, { type: 'endMovement' }); + assert.notEqual(g.active, f.owner); + assert.equal(g.nexusGuildCunningLast?.stage, 'completed'); + }); + } +}