diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 2544b776..e91ef94b 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -1,6 +1,6 @@ # Current development status -Updated 15 October 2026. **The full twelve-faction goal is unfinished. Basic +Updated 16 October 2026. **The full twelve-faction goal is unfinished. Basic is playable and the six classic factions have a user-authorized Advanced preview. Expansion starts and full rules/publication acceptance remain gated.** @@ -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. +**Semuta recovers native Guild cancellation costs — 16 October.** +Printed permission/rate costs now offer the exact public Karama while the +original reserve, cross/return or typed Homeworld parent stays unpaid. +Claim/pass applies the same native cancellation once: no restored route, +retained discount, invented funding or repeated shipment. Original paid +cache/normal auctions provide both physical cards without played-state staging. + +Affected thirteen-file158/158 and32 original-action runtime/policy paths pass, +including both modes and all four profiles for both native families. +Four actual390px cancellation/continuation journeys prove reserve full4, +denied cross plus ordinary1, typed full2 all-seat decline, and unfunded2/full4 +then one-force full2. No overflow, browser error or application exception. +One transient stale-node tool error was followed by fresh evidence, not replay. +No worker/D1/real room, human server, deployment or public gate changed. +Shipment-price activation disposal/refund binding remains separate. +[Canonical source and scoped proof](SEMUTA_SOURCE_UPDATE_20260907.md#native-guild-permission-and-rate-costs--16-october-2026). +Final types/lint and production build pass with existing warnings; the +twelve new cases pass again after fixture-only type narrowing. Current +source links and all five still-partial Semuta facets resolve. + **Native Guild rate with Richese Secret Ally — 15 October.** The original unpaid counter now preserves one billed counter and all five physical forces: sand1→2, not ten. Unfunded full2 keeps forces/spice and the diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index f46b2444..84d5cf9d 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,57 @@ # Dune implementation status +## 16 October 2026 — Semuta retains native Guild permission/rate cancellation + +Added the dedicated `karamaShipmentDiscard` family using the existing +neutral all-seat Semuta offer, original private claimant choice and native +Karama completion. Original unpaid reserve, cross/return and typed Homeworld +frames persist through cost recovery or decline, bound to the exact response, +physical group, approved payment, allowance and independent price source. +Recovering Karama does not undo permission denial or native repricing, +finance an unfunded group, mutate typed sources or repeat shipment. +Existing original-opportunity/no-debt policies are unchanged. + +Original setup selects only conserved undealt lottery/deck order. Actual +Guild public cache purchase pays1 for Semuta; actual Emperor normal auction +pays1 for the original physical Karama. Original native turns and optional +outgoing bribes follow as actions; no played hands, wallets, armies, phases +or decisions are staged. + +Affected thirteen-file **158/158** passes, including twelve new behavior +cases. Thirty-two original-action runtime/policy paths pass: six rate routes, +four permission routes, claim/decline, unaffordable release, both modes and +all four original profiles for both native families. No tuning/calibration. +Two additional explicitly controlled second-card acquisition probes exercise +actual cross/typed permission cancellation and claim with an independent +price active; both preserve its original grant. They do not certify the +separate unfinished shipment-price activation discard producer. + +Four actual styled390×844 GameTable cancellation/continuation journeys begin +at those original-action unpaid responses. Reserve-rate claim pays full4 +for two real counters and retains the recovered cost card. Cross-permission +claim leaves original five-counter source/spice4 unchanged and still denies +that route; a new ordinary1 then leaves Guild3. Typed world all-seat decline +keeps Semuta held and delivers two native counters for full2 once. Poor2 +cannot finance original full4 even after cost recovery, but a new one-force +full2 shipment remains legal. Original turn completion is observed. +Viewport/scroll width390, no browser error or application exception. +One transient stale-node tool error after successful cancellation was +resolved with fresh authoritative state/accessibility without replay. + +Only an owned memory-only host started/stopped. No worker, D1, authenticated +saved room, human server, deployed revision or gate was exercised or changed. +Outside-Git source/API/table proof and four initial scenes remain under +`/tmp/dune-semuta-guild-native-*20261016*`. Shipment-price activation disposal +and provisional special-stop refund binding remain separate. The full goal +and final assurance remain unfinished. +[Source/runtime checkpoint](SEMUTA_SOURCE_UPDATE_20260907.md#native-guild-permission-and-rate-costs--16-october-2026), +[continuation contract](SEMUTA_DISCARD_CONTINUATIONS.md#native-guild-cancellation-cost-family--16-october-2026). +Final `npm run check:quick` and production build pass with existing +module-register, large-chunk and Unknown-route warnings. The twelve new +cases pass again after a fixture-only captured-reaction narrowing repair. +Six current local links and51 evidence paths resolve; all five Semuta +facets stay Partial, without recovery/deployed or complete-mode claims. + ## 15 October 2026 — Native Guild price counter retains Richese one-counter source Removed the explicit Richese Secret Ally exclusion from native rate diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 50960f26..14bbc12d 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -672,6 +672,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP | Guild native rate Karama | Native prices follow original source rather than blanket module flags. Independent Smuggler counters stay free; own/allied No-Field bills one marker, never materialized value. A supplier’s whole-fee1 approval cannot silently become2: release and obtain a new full-price offer/consent. Typed native World/cross/return retains original sources and approved split; deferred Betrayal retains full-price authority until delivery. Earlier bank-only allied-loan, no-debt withdrawal and return policies remain provisional. | [Current composition and proof](GUILD_RATE_KARAMA_RULES.md#permission-purchase-and-allied-marker-composition--15-october-2026) | | Guild rate with Richese Secret Ally | A native Guild holder retains the Nexus source's one-counter bill for up to five real forces. Native cancellation changes sand1→2, not the actual physical count or absent Richese advantage. Independently purchased prices remain separate. Under selected existing no-debt/spent-card composition, unaffordable full2 ends the declared Nexus effect without refund/reusable coupon but preserves the ordinary full-price shipment. A still-held card instead remains available with the correct full quote. This is not a publisher worked timing example. | [Source and explicit policy](NEXUS_RICHESE_RULES.md#native-guild-rate-composition--15-october-2026), [original-action runtime and controls](NEXUS_RICHESE_RUNTIME.md#native-guild-rate-followup--15-october-2026) | | Guild native transport permission | A distinct unpaid cross/return response precedes rate/payment. Under the standing choose-recommendation instruction, the provisional denied scope is that original shipment opportunity, preventing immediate redeclaration while preserving a legal different shipment/movement. Signed Cunning second opportunity is distinct; common World-to-foreign-World and immune Junction permissions are not canceled by analogy. Selected special-stop→permission→rate order is product composition, not a new publisher timing FAQ. | [Policy, source and genuine/controlled evidence](GUILD_RATE_KARAMA_RULES.md#permission-purchase-and-allied-marker-composition--15-october-2026) | +| Semuta on native Guild cancellation costs | A printed permission/rate cancellation cost is a fresh public discard while its original reserve, cross/return or typed Homeworld parent remains unpaid. Claim/all-seat decline restores and applies that same cancellation once. Recovering the cost cannot restore denied transport, keep the native discount, finance an unfunded group or replay shipment. Existing denial/affordability policy is unchanged; shipment-price activation disposal and special-stop refund remain separate. | [Printed-source continuation and scoped proof](SEMUTA_SOURCE_UPDATE_20260907.md#native-guild-permission-and-rate-costs--16-october-2026) | | Purchased Karama typed price | Supplied authorized Advanced PDF physical16 says any player’s Force Shipment may be half normal, paid to bank. Original typed routes use that independent tariff without new route rights or Nexus stacking; the entire approved invoice is bank-only. Completion consumes only the original grant. Special-stop uses existing provisional card-return parity, refunding the exact original physical card to its owner. Immune Junction uses the separate source choice below, not this card tariff. | [Connected typed routes and source distinction](GUILD_HOMEWORLD_TRANSPORT_RULES.md#separate-permission-and-purchased-price--15-october-2026) | | Purchased price with immune Junction | Keep the original offered half/full tariff and normal payees; no quarter price or bank-only overlay. Under the standing choose-recommendation instruction, accepting this source with a purchased grant requires explicit acknowledgment that it ends unused, with no card refund. Consume only upon actual delivery, not failed, stale, unfunded or deferred acceptance; the ordinary purchased route remains available. This is a conservative product composition, not a publisher worked example. | [Source immunity and selected choice](JUNCTION_TRANSPORT_RULES.md#purchased-price-and-immune-offer--15-october-2026), [original versus controlled proof](JUNCTION_TRANSPORT_RUNTIME.md#purchased-price-source-choice--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) | diff --git a/docs/SEMUTA_DISCARD_CONTINUATIONS.md b/docs/SEMUTA_DISCARD_CONTINUATIONS.md index 58ee043f..09341a97 100644 --- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md +++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md @@ -2,6 +2,29 @@ Read-only engine audit, 2026-09-07. Read the complete `docs/SEMUTA_DRUG_ENGINE_AUDIT.md`, then inspected every current `discard(...)` call, direct Treachery-discard mutation and `draw(g)` caller in `game/engine.ts`, plus the Truthtrance discard callback and pure Box/Distrans results. Names below are integration anchors; approximate current lines are navigation aids only. This report adopts no additional source ruling and changes no project file. +## Native Guild cancellation cost family — 16 October 2026 + +`karamaShipmentDiscard` now suspends a printed cancellation cost for original +native permission/rate responses. Pending reserve, cross/return or typed +Homeworld frames remain in their own custody while the response is parked. +Existing response collectors see that saved response; an admitted Semuta +claim or all-seat decline restores it and finishes the cancellation once. + +Unlike the already-paid income family, no shipment payment or physical +departure has happened. Recovering the cost cannot create a route, keep +half price, finance the unfunded group or repeat a delivery. Signature and +existing native validators bind the original group, approved split, source +books, allowance and any independent price grant. Ordinary permission and +affordability interpretations are unchanged. + +The [current source/runtime checkpoint](SEMUTA_SOURCE_UPDATE_20260907.md#native-guild-permission-and-rate-costs--16-october-2026) +records158 affected cases,32 original runtime/policy paths and four actual +mobile cancellation/continuation journeys. These are changed-rule proofs, +not a new broad save/privacy/custody campaign or complete-mode acceptance. +Shipment-price activation disposal and special-stop refund composition +remain separate work. + + Update: the exchange producers, three battle producers, Sabotage, Robbery overflow and completed Nullentropy Box searches, Ornithopter escrow retirement, definite Truthtrance answers and the five ordinary completed-card effects below now have production automatic continuation frames; see [exchange checkpoint](TREACHERY_DISCARD_CONTINUATIONS.md), [battle checkpoint](BATTLE_DISCARD_CONTINUATIONS.md), [forced Terror checkpoint](TERROR_DISCARD_CONTINUATIONS.md), [Box checkpoint](BOX_DISCARD_CONTINUATIONS.md), [Ornithopter checkpoint](ORNITHOPTER_DISCARD_CONTINUATIONS.md), [Truthtrance checkpoint](TRUTHTRANCE_DISCARD_CONTINUATIONS.md) and [ordinary-card checkpoint](ORDINARY_CARD_DISCARD_CONTINUATIONS.md). The remaining table is an implementation inventory, not a claim that Semuta is active. 30 September 2026 checkpoint: the explicit Semuta development preview diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index 3a4dd7d4..0395ba55 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -2,6 +2,58 @@ 7 September 2026. Read `docs/SEMUTA_DRUG_ENGINE_AUDIT.md` in full, inspected the physical card again, checked current capacity/recovery code, and freshly searched publisher sources. No project runtime edits. +## Native Guild permission and rate costs — 16 October 2026 + +Printed Karama cancellation of native `guildTransportPermission` or +`guildRate` now offers its exact public cost through the existing neutral +Semuta mechanism. This covers original reserve shipping, cross/return and +typed Homeworld parents. The three physical parent frames remain unpaid +while another player claims the cost or every seat declines. + +The dedicated `karamaShipmentDiscard` continuation binds the original +response, source group, allowance, approved payment, native cancellation +state, physical pools and independent purchased-price grant. Claim/pass +restores that response and applies the original cancellation once. Recovering +Karama neither restores denied transport nor removes the full-price invoice, +finances an unfunded group, changes typed sources or repeats shipment. +The existing permission-scope and no-debt policies are unchanged. + +Affected thirteen-file **158/158** passes, including twelve new behavior +cases. Thirty-two original-action runtime/policy paths pass, including +Basic/Advanced, six rate routes, four permission routes, unaffordable +release and all four profiles for each native family. Original setup selects +only undealt entropy/deck order; Guild buys Semuta in a real cache auction +and Emperor buys the physical Karama in a real normal auction. No played +hand, wallet, army, phase or response is assigned. +Two additional **controlled** probes conserve a second physical card into +Atreides custody, then actually activate its price and use the original +cross/typed permission, cancellation and claim. Both retain that exact +independent grant without payment or restoring native permission. They do +not verify the separate shipment-price activation discard producer. +Proof: `/tmp/dune-semuta-native-active-price-20261016.json`. + +Four actual styled390×844 GameTable journeys begin at those original-action +unpaid responses: reserve-rate claim/full4, cross-permission claim then +ordinary1, typed world-rate all-seat decline/full2, and poor2/full4 release +then a new one-force full2 replacement. The recovered card remains held, +not automatically replayed. Viewport/scroll width390, no browser error or +application exception. One transient stale-node browser-tool error was +followed by fresh authoritative state/accessibility, never action replay. + +Only an owned memory-only loopback host ran; no worker, D1, authenticated +saved room, human server, deployment or mode gate was changed. Source/API/ +table proof and four initial scenes remain outside Git under +`/tmp/dune-semuta-guild-native-*20261016*`. +Shipment-price activation costs and their provisional special-stop refund +binding remain separate; this does not certify complete Semuta/module play. +[Continuation inventory](SEMUTA_DISCARD_CONTINUATIONS.md#native-guild-cancellation-cost-family--16-october-2026). +Final types/lint and production build pass; existing module-register, +large-chunk and Unknown-route warnings remain. The twelve new behavior +cases pass again after the fixture-only TypeScript narrowing repair. +Six current local links and51 Semuta evidence paths resolve; all five +Semuta facets remain Partial. No final recovery or deployed claim is made. + + ## User-selected full-hand exchange — 30 September 2026 The user selected **Allow exchange**: treat the acquisition and diff --git a/game/engine.ts b/game/engine.ts index c7ecd3b2..1ccce9d3 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1394,7 +1394,7 @@ export type Game = { stateSignature: string; } | { - kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard'; + kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard'; player: string; card: string; use: Extract; @@ -5433,15 +5433,31 @@ function semutaIncomeKaramaResponse(g: Game, response: ResponseWindow): boolean response.amount === 3 && !!purchase && purchase.owner === owner.id && purchase.turn === g.turn && purchase.phase === g.phase; } +/** The printed cost is fresh while its original native transport remains unpaid. */ +function semutaGuildShipmentKaramaResponse(g: Game, response: ResponseWindow): boolean { + if ((response.kind !== 'guildRate' && response.kind !== 'guildTransportPermission') || + g.phase !== 5 || g.battle || response.owner !== byFaction(g, 'guild')?.id || + Number(!!g.pendingShipment) + Number(!!g.pendingGuildTransport) + + Number(!!g.pendingHomeworldShipment) !== 1) return false; + if (response.kind === 'guildTransportPermission') { + if (g.pendingHomeworldShipment) validateHomeworldTransportPermission(g, response); + else if (g.pendingGuildTransport) validateGuildTransportPermissionResponse(g, response); + else return false; + } else if (g.pendingHomeworldShipment) validateHomeworldGuildRateResponse(g, response); + else if (g.pendingGuildTransport) validateGuildTransportRateResponse(g, response); + else validateGuildRateResponse(g, response); + return true; +} type KaramaResponseDiscardContinuation = Extract< NonNullable['continuation'], - { kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' } + { kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' } >; function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinuation) { const battle = c.kind === 'karamaBattleDiscard'; const worthless = c.kind === 'karamaChoamWorthlessDiscard'; const emperorShare = c.kind === 'karamaEmperorShareDiscard'; const income = c.kind === 'karamaIncomeDiscard'; + const shipment = c.kind === 'karamaShipmentDiscard'; return JSON.stringify({ player: c.player, card: c.card, use: c.use, parent: nullentropyParentSignature(g, c.resume, @@ -5477,13 +5493,24 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu percentages: g.homeworldOccupiedPercentageLedger, } : undefined, } : undefined, + shipment: shipment ? { + reserve: g.pendingShipment, transport: g.pendingGuildTransport, + homeworld: g.pendingHomeworldShipment, custody: g.homeworlds, + occupation: g.homeworldOccupationHistory, aid: g.aid, + rate: g.guildRateBlocked, permission: g.guildTransportBlocked, + purchased: g.karamaShipping, cunning: g.nexusGuildCunningHistory?.at(-1), + active: g.active, ready: g.ready, movementRemaining: g.movementRemaining, + } : undefined, order: g.order, players: g.players.map(p => ({ id: p.id, faction: p.faction, ally: p.ally, hand: p.hand.map(card => card.id), spice: p.spice, - shipped: income ? p.shipped : undefined, - physical: battle || worthless || income ? { forces: p.forces, reserves: p.reserves, tanks: p.tanks, - elites: p.elites, leaders: battle ? p.leaders : undefined } : undefined, + shipped: income || shipment ? p.shipped : undefined, + physical: battle || worthless || income || shipment + ? { forces: p.forces, reserves: p.reserves, tanks: p.tanks, + elites: p.elites, leaders: battle ? p.leaders : undefined, + noField: shipment ? p.noField : undefined, + noFieldEvent: shipment ? p.noFieldEvent : undefined } : undefined, })), }); } @@ -6278,7 +6305,8 @@ function treacheryDiscardIntegrity(g: Game) { continuation?.kind === 'karamaBattleDiscard' || continuation?.kind === 'karamaChoamWorthlessDiscard' || continuation?.kind === 'karamaEmperorShareDiscard' || - continuation?.kind === 'karamaIncomeDiscard') { + continuation?.kind === 'karamaIncomeDiscard' || + continuation?.kind === 'karamaShipmentDiscard') { const c = continuation, entry = batch.entries[0]; requireRule( !g.response && @@ -6300,7 +6328,9 @@ function treacheryDiscardIntegrity(g: Game) { (c.kind === 'karamaEmperorShareDiscard' && semutaEmperorShareKaramaResponse(g, c.use.response)) || (c.kind === 'karamaIncomeDiscard' && - semutaIncomeKaramaResponse(g, c.use.response))) && + semutaIncomeKaramaResponse(g, c.use.response)) || + (c.kind === 'karamaShipmentDiscard' && + semutaGuildShipmentKaramaResponse(g, c.use.response))) && c.use.response.owner !== c.player && c.resume.response?.kind === c.use.response.kind && JSON.stringify(c.resume.response) === JSON.stringify(c.use.response) && @@ -7153,6 +7183,7 @@ function semutaOfferSupported( continuation.kind !== 'karamaChoamWorthlessDiscard' && continuation.kind !== 'karamaEmperorShareDiscard' && continuation.kind !== 'karamaIncomeDiscard' && + continuation.kind !== 'karamaShipmentDiscard' && continuation.kind !== 'kaitainDiscard' && continuation.kind !== 'tupileCleanupDiscard' && continuation.kind !== 'karamaPurchaseDiscard' && @@ -7185,6 +7216,7 @@ function semutaOfferSupported( if (g.homeworlds && continuation.kind !== 'kaitainDiscard' && continuation.kind !== 'tupileCleanupDiscard' && continuation.kind !== 'karamaIncomeDiscard' && + continuation.kind !== 'karamaShipmentDiscard' && continuation.kind !== 'ordinaryCardDiscard' && continuation.kind !== 'nullentropyDiscard' && continuation.kind !== 'distransDiscard' && @@ -7269,7 +7301,8 @@ function semutaOfferSupported( continuation.kind === 'karamaBattleDiscard' || continuation.kind === 'karamaChoamWorthlessDiscard' || continuation.kind === 'karamaEmperorShareDiscard' || - continuation.kind === 'karamaIncomeDiscard' + continuation.kind === 'karamaIncomeDiscard' || + continuation.kind === 'karamaShipmentDiscard' ? (continuation.kind === 'karamaBattleDiscard' ? semutaBattleKaramaResponse(g, continuation.use.response) : continuation.kind === 'karamaChoamWorthlessDiscard' @@ -7278,7 +7311,9 @@ function semutaOfferSupported( ? semutaEmperorShareKaramaResponse(g, continuation.use.response) : continuation.kind === 'karamaIncomeDiscard' ? semutaIncomeKaramaResponse(g, continuation.use.response) - : ['choamCharity', 'choamInflation', 'bgCharity'].includes(continuation.resume.response?.kind ?? '')) && + : continuation.kind === 'karamaShipmentDiscard' + ? semutaGuildShipmentKaramaResponse(g, continuation.use.response) + : ['choamCharity', 'choamInflation', 'bgCharity'].includes(continuation.resume.response?.kind ?? '')) && !continuation.resume.decision && !continuation.resume.pendingKarama && !continuation.resume.phaseOpening && !g.pendingKarama && (!g.response || JSON.stringify(g.response) === JSON.stringify(continuation.resume.response)) @@ -7305,12 +7340,13 @@ function semutaOfferSupported( continuation.kind === 'karamaChoamWorthlessDiscard' || continuation.kind === 'karamaEmperorShareDiscard' || continuation.kind === 'karamaIncomeDiscard' || + continuation.kind === 'karamaShipmentDiscard' || (!continuation.resume.response && !continuation.resume.decision && !continuation.resume.pendingKarama && (continuation.kind === 'amalDiscard' ? continuation.resume.phaseOpening?.passed.length === 0 : !continuation.resume.phaseOpening)))) && - !g.karamaShipping && + (continuation.kind === 'karamaShipmentDiscard' || !g.karamaShipping) && (continuation.kind === 'ixAllyCard' ? !!g.currentAuctionSale && continuation.sale.origin === g.currentAuctionSale.origin && (continuation.sale.origin === 'normal' @@ -7351,8 +7387,10 @@ function semutaOfferSupported( !g.pendingArrivalOverlap && !g.pendingRevival && !g.ornithopter && !g.ecologicalStorm)) && !g.pendingExchange && !g.pendingIxAlly && !g.pendingNullentropy && - !g.pendingAmbassador && !g.pendingTerrorEntry && !g.pendingShipment && - !g.pendingHomeworldShipment && !g.pendingRicheseGift && + !g.pendingAmbassador && !g.pendingTerrorEntry && + (continuation.kind === 'karamaShipmentDiscard' || + (!g.pendingShipment && !g.pendingHomeworldShipment)) && + !g.pendingRicheseGift && ((continuation.kind === 'karamaIncomeDiscard' && continuation.use.response.kind === 'richesePurchaseIncome') || !g.pendingRichesePurchaseIncome) && (continuation.kind === 'choamSaleDiscard' @@ -7585,6 +7623,7 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string next.kind === 'karamaChoamWorthlessDiscard' || next.kind === 'karamaEmperorShareDiscard' || next.kind === 'karamaIncomeDiscard' || + next.kind === 'karamaShipmentDiscard' || next.kind === 'karamaPurchaseDiscard' || next.kind === 'karamaPaymentDiscard') { g.response = next.resume.response; @@ -14224,6 +14263,8 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered 'Kull preview cannot accept this unproven Truthtrance counter continuation.'); if (!kullOffered && beginKullAttempt(g, p, card, { kind: 'ordinary', use }, promiseProven)) return; const used = discard(g, p, card.id); + const guildShipmentDiscard = g.semutaPreview && card.effect === 'karama' && + use.kind === 'cancel' && semutaGuildShipmentKaramaResponse(g, use.response); if (g.semutaPreview && card.effect === 'karama' && use.kind === 'cancel' && (use.response.kind === 'choamCharity' || @@ -14231,10 +14272,10 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered use.response.kind === 'bgCharity' || semutaBattleKaramaResponse(g, use.response) || semutaChoamKaramaResponse(g, use.response) || semutaEmperorShareKaramaResponse(g, use.response) || - semutaIncomeKaramaResponse(g, use.response))) { + semutaIncomeKaramaResponse(g, use.response) || guildShipmentDiscard)) { const continuation: KaramaResponseDiscardContinuation = { - kind: use.response.kind === 'choamCharity' - ? 'karamaCharityDiscard' + kind: guildShipmentDiscard ? 'karamaShipmentDiscard' + : use.response.kind === 'choamCharity' ? 'karamaCharityDiscard' : use.response.kind === 'choamInflation' ? 'karamaInflationDiscard' : use.response.kind === 'bgCharity' ? 'karamaBgCharityDiscard' diff --git a/game/reference.ts b/game/reference.ts index 01e04bd7..a417dae9 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1201,6 +1201,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'A distinct printed Karama canceling those five ordinary native CHOAM printed or Cunning powers also offers its public cost before the original denial completes. CHOAM retains the declared fuel; a Cunning Nexus remains spent. Claim or Continue restores the same saved response and one original movement, free-revival, storm or closing-Mentat suffix. Acquiring Karama cannot return the canceled force, grant range, stop the original request, protect the storm or restore Nexus. The retained CHOAM fuel is not a discard candidate; Kull and unsupported arrival/flight parents remain separate.', 'A printed Karama canceling the Emperor’s shared gift or extra allied revival also offers that exact public cost before the denial completes. The canceled share moves no spice, forces or revival; the Emperor’s ally and extra-revival ledger are unchanged. Claim or Continue restores the saved response and finishes the cancellation once. Only the actual Semuta holder sees the private claimant choice; other seats see the neutral offer.', 'A printed Karama canceling original paid Guild shipment income, non-Ambassador Emperor auction income or Richese special-purchase income also offers its exact public cost before the native denial. The price and physical transaction have already committed: claim or Continue cannot refund spice, re-ship forces, undo cache acquisition or replay a purchase. Original Movement, next-lot inspection or the Richese purchase’s suspended controls resume once. A winner may spend the very Karama it just bought; claiming that identity does not reverse its payment. Homeworld and Ambassador payment compositions remain separate.', + 'A printed Karama canceling native Guild transport permission or shipment rate now offers that exact public cost while the original reserve, cross/return or typed Homeworld declaration remains unpaid. Claim or Continue restores and applies that cancellation once. Recovering the cost never restores a denied route, retains the native discount, finances an unfunded group or repeats shipment. Ordinary movement and legal replacements remain. Independent price grants stay distinct; shipment-price activation disposal and special-stop refund binding remain separate.', 'The separate fresh semuta --homeworlds entry retains original Richese/CHOAM and classic Homeworld occupation setup with exact CHOAM and no other modules. Paid Box search, clean Distrans transfer, definite Truthtrance answers, used ordinary specials, paid Kaitain disposal, Tupile cleanup and printed Karama canceling native Guild/Emperor/Richese-purchase income offer Semuta. Native printed Kulon, Baliset, La La La, Trip to Gamont and Jubba costs also pause before their original effect: claim/pass adds one movement bonus, denies free allowance without charging/moving forces, stops the uncommitted Baliset move, returns one original board counter before the same CHOAM close, or applies protection before the same recorded storm moves. Printed Karama canceling these powers pauses separately before denial; the retained Worthless fuel is not a candidate. A canceled reactive Baliset resumes its original declared move once; canceled Kulon/La La La/Gamont/Jubba do not apply their effects. A recovered card grants no other faction CHOAM power. Kaitain private groups require commitment before selection; each Tupile holder has a separate cost. Native income denial retains payment and the original occupied-share allocation. Basic unresolved epochs, Kull, other producers and public starts remain guarded.', 'Legal completed CHOAM market sales also offer their used card in the Homeworld profile. Worthless cashout keeps its original two-spice credit; a non-Worthless duplicate keeps its three-spice credit and the original same-name witness stays held. The high-Tupile Worthless-sale prohibition still applies, including duplicate Worthless sales. Claim/pass cannot repeat payout, undo the actual reserve shipment or replace the original market close; no new bank-income interpretation is made.', 'Final Ornithopter disposal also offers its used card in the Homeworld profile after the one-group range-three or original two-group movement completes, including an early end with no useful original group. Claim/pass cannot move those counters again or replay the paid native shipment; prior-move/Hajr, advisor and Homeworld-transfer boundaries remain. Clean Sapho Once Around and unstarted Movement first/last costs likewise offer after reordering the finite unacted queue. Earlier bids/funding and physical storm order stay committed, and the same next bidder or combined turn resumes once. Cyclic auctions, partly completed turns, first-versus-Guild and late battle timing are not opened.', @@ -1225,31 +1226,31 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation' as const, status: 'Partial' as const, detail: - 'The Richese preview interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity or settling a normal auction purchase or winning bid, a completed CHOAM sale, clean Kulon, proactive or reactive La La La/Baliset and ordinary Trip to Gamont Worthless use, accepted printed Jubba before storm protection, Ix-deck Thumper/Amal and three battle discard stages, including before native Advanced Harkonnen capture. A private normal-auction or Black Market Ixian ally replacement pauses before its draw with a neutral, face-hidden offer; original payment and seller income settle once. Cache purchases retain their native allied-replacement prohibition. Ordinary definite Truthtrance answers pause whether or not another question remains reserved, and also when the answer bound a supported shipment or unsubmitted battle-plan promise. CHOAM sale income is committed before its neutral offer and the same market resumes once; Kulon adds one movement range, La La La blocks one target’s free revival, Baliset restricts the chosen player’s movement, ordinary Trip returns one selected force, and Jubba protects one CHOAM territory only after their offers. Karama’s physical cost is discarded before its neutral offer; the original response is canceled or the reserved lot settles once. Residual Poison commits one random leader death; Sapho commits its finite priority. Thumper waits before worm/spice draw; Amal restores an already-halved opening. Owner-only candidates and one physical Semuta transfer retain privacy. The user-approved full-hand exchange preserves final hand count and incoming reservations. Other Karama uses, other reactive Worthless uses, other private discards, nested responses and other pending rewards remain unfinished.', + 'The Richese preview interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity or settling a normal auction purchase or winning bid, a completed CHOAM sale, clean Kulon, proactive or reactive La La La/Baliset and ordinary Trip to Gamont Worthless use, accepted printed Jubba before storm protection, Ix-deck Thumper/Amal and three battle discard stages, including before native Advanced Harkonnen capture. A private normal-auction or Black Market Ixian ally replacement pauses before its draw with a neutral, face-hidden offer; original payment and seller income settle once. Cache purchases retain their native allied-replacement prohibition. Ordinary definite Truthtrance answers pause whether or not another question remains reserved, and also when the answer bound a supported shipment or unsubmitted battle-plan promise. CHOAM sale income is committed before its neutral offer and the same market resumes once; Kulon adds one movement range, La La La blocks one target’s free revival, Baliset restricts the chosen player’s movement, ordinary Trip returns one selected force, and Jubba protects one CHOAM territory only after their offers. Karama’s physical cost is discarded before its neutral offer; the original response is canceled or the reserved lot settles once. Residual Poison commits one random leader death; Sapho commits its finite priority. Thumper waits before worm/spice draw; Amal restores an already-halved opening. Owner-only candidates and one physical Semuta transfer retain privacy. The user-approved full-hand exchange preserves final hand count and incoming reservations. Other Karama uses, other reactive Worthless uses, other private discards, nested responses and other pending rewards remain unfinished. Printed native Guild permission/rate costs now use a dedicated unpaid shipment continuation for reserve, cross/return and typed Homeworld sources; claim/pass applies the same denial/repricing once without delivery replay or invented funding.', }, { area: 'Player controls' as const, status: 'Partial' as const, detail: - 'All seats can Continue at the neutral fresh-discard boundary; only the actual holder sees Commit. In a mixed-owner battle batch, a committed holder can inspect and select only cards discarded by another player; nonholders receive no candidates. Normal Richese starts remain gated.', + 'All seats can Continue at the neutral fresh-discard boundary; only the actual holder sees Commit. In a mixed-owner battle batch, a committed holder can inspect and select only cards discarded by another player; nonholders receive no candidates. Normal Richese starts remain gated. Four current original-action390px cost/continuation journeys exercise full4 reserve claim, denied cross plus ordinary1, typed full2 all-seat decline, and poor2/full4 release then one-force full2. No overflow/browser/application error; one tool-only stale-node race was resolved with fresh evidence, not replay.', }, { area: 'AI' as const, status: 'Partial' as const, detail: - 'All four AI profiles use their own projected hand to commit or pass the exact current event, and can choose only privately projected committed candidates. This is minimal legal participation, not tuned strategy.', + 'All four AI profiles use their own projected hand to commit or pass the exact current event, and can choose only privately projected committed candidates. This is minimal legal participation, not tuned strategy. For both new native permission and rate cost parents, all four actual original-state profiles recover the exact cost through legal actions and preserve the original denial/repricing. No tuning or calibration.', }, { area: 'Documentation' as const, status: 'Partial' as const, detail: - 'The guide distinguishes clean public ordinary-card, paid Box, Ornithopter, Distrans, definite ordinary Truthtrance answers (including queued questions, supported shipment promises and battle answers before or after plan submission/reveal), four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, completed CHOAM card sales, clean Kulon/proactive La La La/Baliset/ordinary Trip to Gamont Worthless and accepted printed Jubba Cloak, Ix-deck Thumper/Amal and three battle batches from a private normal-auction or Black Market Ixian ally replacement, the cache replacement prohibition, privacy-neutral acknowledgements, the user-approved atomic full-hand exchange and remaining discard sources.', + 'The guide distinguishes clean public ordinary-card, paid Box, Ornithopter, Distrans, definite ordinary Truthtrance answers (including queued questions, supported shipment promises and battle answers before or after plan submission/reveal), four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, completed CHOAM card sales, clean Kulon/proactive La La La/Baliset/ordinary Trip to Gamont Worthless and accepted printed Jubba Cloak, Ix-deck Thumper/Amal and three battle batches from a private normal-auction or Black Market Ixian ally replacement, the cache replacement prohibition, privacy-neutral acknowledgements, the user-approved atomic full-hand exchange and remaining discard sources. The native Guild followup distinguishes unpaid transport from completed income and keeps existing denial/no-debt interpretations. Both original cards are actually auctioned; two extra active-price probes explicitly disclose controlled second-card acquisition. Shipment-price activation disposal/refund remains separate.', }, { area: 'Verification' as const, status: 'Partial' as const, detail: - 'Genuine setup, custody, hidden-hand parity, saved JSON, authenticated SQLite continuation and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, completed CHOAM card sales, clean Kulon/proactive La La La/Baliset/ordinary Trip to Gamont Worthless and accepted printed Jubba Cloak, Ix-deck Thumper/Amal, a private normal-auction Ixian ally replacement and three battle discard stages. Other private producers, Karama uses, other reactive Worthless uses, pending rewards, full games and live deployment remain unverified.', + 'Genuine setup, custody, hidden-hand parity, saved JSON, authenticated SQLite continuation and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, completed CHOAM card sales, clean Kulon/proactive La La La/Baliset/ordinary Trip to Gamont Worthless and accepted printed Jubba Cloak, Ix-deck Thumper/Amal, a private normal-auction Ixian ally replacement and three battle discard stages. Other private producers, Karama uses, other reactive Worthless uses, pending rewards, full games and live deployment remain unverified. Current thirteen-file158/158 passes, including12 new cases. Thirty-two original-action runtime/policy paths and four original-action390px journeys pass; two separately controlled cross/typed active-price probes retain the original independent grant. No worker/D1/real-room/deployed assurance or complete-mode claim.', evidence: [ 'tests/semuta-engine.test.ts', 'tests/semuta-recovery.test.ts', @@ -1301,6 +1302,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'tests/semuta-homeworld-emperor-share.test.ts', 'tests/semuta-homeworld-capture-karama.test.ts', 'tests/semuta-homeworld-choam-battle-karama.test.ts', + 'tests/semuta-guild-transport.test.ts', ], }, ], diff --git a/tests/fixture-semuta-guild-transport.ts b/tests/fixture-semuta-guild-transport.ts new file mode 100644 index 00000000..9ceadcf3 --- /dev/null +++ b/tests/fixture-semuta-guild-transport.ts @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, + viewGame, type Action, type Game, +} from '../game/engine'; +import { TERRITORIES } from '../game/board'; +import { presenceAt } from '../game/force-presence'; +import { homeworldShipmentChoice } from '../game/homeworld-shipment-options'; +import { guildHomeworldShipmentChoice } from '../game/guild-homeworld-shipment-options'; +import { nextSemutaGuildIncomeNativeStep } from './fixture-semuta-guild-income'; +import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus'; + +export type SemutaGuildRoute = 'reserve' | 'cross' | 'return' | 'world' | 'homeworldReturn' | 'homeworldForeign'; +export type SemutaGuildStep = { actor: string; action: Action }; +const player = (game: Game, actor: string) => game.players.find(p => p.id === actor)!; +const clean = (game: Game) => !game.response && !game.decision && !game.phaseOpening && !game.pendingTreacheryDiscard; + +/** No played hand/army/wallet/phase is staged: real cache purchase gives Guild + * Semuta, a real normal auction gives Emperor Karama, and native play reaches + * the original Guild turn. Only original undealt lotteries/deck order are selected. */ +export function createSemutaGuildTransport(options: { + route?: SemutaGuildRoute; + cancel?: 'rate' | 'permission'; + poor?: boolean; + advanced?: boolean; +} = {}) { + const route = options.route ?? 'reserve', kind = options.cancel ?? 'rate'; + const homeworlds = route === 'world' || route === 'homeworldReturn' || route === 'homeworldForeign'; + let game = createGame('SEMUTAGUILDNATIVE', newPlayer('r', 'Richese', 'richese'), + options.advanced ?? true, ['choam']); + for (const [id, name, faction] of [['g', 'Guild', 'guild'], ['a', 'Atreides', 'atreides'], ['e', 'Emperor', 'emperor']] as const) + joinGame(game, newPlayer(id, name, faction)); + if (homeworlds) game = applyAction(game, 'r', { type: 'homeworlds', enabled: true }); + for (const seat of game.players) game = applyAction(game, seat.id, { type: 'ready' }); + game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game)); + const actions: SemutaGuildStep[] = []; + const act = (actor: string, action: Action) => { + actions.push({ actor, action }); game = applyAction(game, actor, action); + }; + const reach = (until: (state: Game) => boolean) => { + for (let n = 0; !until(game) && n < 1600; n++) { + assert.notEqual(game.status, 'finished'); + const next = nextSemutaGuildIncomeNativeStep(game); act(next.actor, next.action); + } + assert.ok(until(game), `Original Semuta programme stalled at ${game.turn}/${game.phase}/${game.decision?.kind}`); + }; + reach(state => state.status === 'playing'); + const setup = structuredClone(game); + const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0); + const karama = game.deck.splice(index, 1)[0]; game.deck.unshift(karama); + for (const position of [0, 1]) { + const at = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card); + assert.ok(at >= position); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(at, 1)[0]); + } + reach(state => state.decision?.kind === 'richeseCache'); + act('r', { type: 'decision', event: game.richeseBidding!.event, + card: 'richese-semuta-drug', method: 'onceAround', direction: 'counterclockwise' }); + reach(state => !!state.richeseAuction && !state.richeseAuction.outcome && + state.richeseAuction.active === 'g' && clean(state)); + act('g', { type: 'richeseBid', event: game.richeseAuction!.event, amount: 1 }); + reach(state => player(state, 'g').hand.some(card => card.id === 'richese-semuta-drug') && clean(state)); + const boughtSemuta = structuredClone(game); + reach(state => !!state.auction && state.auction.index === 0 && state.auction.active === 'e' && clean(state)); + act('e', { type: 'bid', amount: 1 }); + reach(state => player(state, 'e').hand.some(card => card.id === karama.id) && clean(state)); + const boughtKarama = structuredClone(game); + reach(state => state.phase === 5 && state.active === 'g' && clean(state)); + if (options.poor) { + const excess = player(game, 'g').spice - 2; assert.ok(excess >= 0); + if (excess) act('g', { type: 'bribe', target: 'e', amount: excess }); + } + const beforeAction = structuredClone(game); + const destination = TERRITORIES.find(t => t.type === 'sand' && + t.sectors.some(s => s !== game.storm) && game.players.every(p => presenceAt(p, t.id) === 0)); + assert.ok(destination); const sector = destination.sectors.find(s => s !== game.storm)!; + const source = Object.entries(player(game, 'g').forces).find(([key, amount]) => + amount >= 2 && Number(key.split(':')[1]) !== game.storm)?.[0]; + assert.ok(source); + let target = `${destination.id}:${sector}`, action: Action; + if (route === 'reserve') action = { type: 'ship', territory: destination.id, sector, amount: 2, elite: 0, allyPayment: 0 }; + else if (route === 'cross' || route === 'return') { + if (route === 'return') target = 'reserves'; + action = { type: 'guildShip', from: source, territory: route === 'return' ? 'reserves' : destination.id, + sector: route === 'return' ? 0 : sector, amount: 2, elite: 0, allyPayment: 0 }; + } else { + target = route === 'homeworldReturn' ? 'homeworld:guild' : 'homeworld:atreides'; + const sources = { [route === 'world' ? 'homeworld:guild' : source]: { normal: 2, elite: 0 } }; + const quote = route === 'world' ? homeworldShipmentChoice(viewGame(game, 'g'), target, sources) + : guildHomeworldShipmentChoice(viewGame(game, 'g'), target, sources); + assert.ok(quote.action, quote.blocked ?? undefined); action = quote.action; + } + act('g', action); + if (game.decision?.kind === 'guildShipment') act('g', { type: 'decision', allow: true }); + else if (game.decision?.kind === 'homeworldShipmentGuild') + act('g', { type: 'decision', event: game.decision.event, allow: true }); + if (kind === 'rate') { + for (let n = 0; game.response?.kind === 'guildTransportPermission' && n < 20; n++) { + const next = nextSemutaGuildIncomeNativeStep(game); act(next.actor, next.action); + } + } + assert.equal(game.response?.kind, kind === 'rate' ? 'guildRate' : 'guildTransportPermission'); + assert.ok(viewGame(game, 'e').responseControls?.cancelCards.includes(karama.id)); + return { setup, boughtSemuta, boughtKarama, beforeAction, beforeCost: structuredClone(game), + game, route, kind, source, target, destination: destination.id, sector, action, actions, + guild: 'g', holder: 'g', spender: 'e', karama: karama.id, semuta: 'richese-semuta-drug', + cancel: { type: 'card', mode: 'cancel', card: karama.id } as Action, + provenance: [ + 'Original Semuta setup; selected original undealt lottery and conserved unplayed Spice/Treachery deck order.', + 'Guild buys actual public Semuta cache lot for1; Emperor buys actual normal Karama auction for1.', + 'Original native Guild turn, original source forces and actual permission/interception; optional real outgoing bribe only.', + ] }; +} + +export function reactSemutaGuildTransport(game: Game, claim: boolean) { + const pending = game.pendingTreacheryDiscard; assert.ok(pending); + const reaction = pending.reaction; + assert.ok(reaction?.stage === 'offer'); + const event = pending.batch.event; + if (claim) return applyAction(game, 'g', { type: 'semutaCommit', event }); + for (const seat of game.players.filter(p => !reaction.passed.includes(p.id))) + game = applyAction(game, seat.id, { type: 'semutaPass', event }); + return game; +} diff --git a/tests/semuta-guild-transport.test.ts b/tests/semuta-guild-transport.test.ts new file mode 100644 index 00000000..813b064f --- /dev/null +++ b/tests/semuta-guild-transport.test.ts @@ -0,0 +1,115 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, type Action, type Game } from '../game/engine'; +import { + createSemutaGuildTransport, reactSemutaGuildTransport, type SemutaGuildRoute, +} from './fixture-semuta-guild-transport'; + +const player = (game: Game) => game.players.find(p => p.id === 'g')!; +function reject(game: Game, actor: string, action: Action) { + const before = structuredClone(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before); +} +function costCustody(game: Game, f: ReturnType, claim: boolean) { + assert.equal(player(game).hand.some(card => card.id === f.karama), claim); + assert.equal(player(game).hand.some(card => card.id === f.semuta), !claim); + assert.equal(game.discard.filter(card => card.id === f.karama).length, claim ? 0 : 1); + assert.equal(game.discard.filter(card => card.id === f.semuta).length, claim ? 1 : 0); +} +function unpaid(game: Game, before: Game) { + assert.equal(player(game).spice, player(before).spice); + assert.equal(player(game).reserves, player(before).reserves); + assert.deepEqual(player(game).forces, player(before).forces); + assert.deepEqual(game.homeworlds, before.homeworlds); + assert.equal(player(game).shipped, false); + assert.equal(player(game).moved, 0); +} + +for (const route of ['reserve', 'cross', 'return', 'world', 'homeworldReturn', 'homeworldForeign'] as SemutaGuildRoute[]) + void test(`Semuta claim or decline preserves original native ${route} full-price delivery`, () => { + for (const claim of [false, true]) { + const f = createSemutaGuildTransport({ route }); + let game = applyAction(f.beforeCost, f.spender, f.cancel); + assert.equal(game.pendingTreacheryDiscard?.batch.cause, 'karama:guildRate'); + unpaid(game, f.beforeAction); + reject(game, f.guild, f.action); + const event = game.pendingTreacheryDiscard!.batch.event; + game = reactSemutaGuildTransport(game, claim); + const p = player(game), original = player(f.beforeAction); + assert.equal(p.spice, route === 'reserve' || route === 'cross' ? 0 : 2); + assert.equal(p.shipped, true); + assert.equal(p.moved, 0); + assert.equal(p.reserves, original.reserves + + (route === 'return' || route === 'homeworldReturn' ? 2 : + route === 'reserve' || route === 'world' ? -2 : 0)); + assert.equal(p.forces[f.source], original.forces[f.source] - + (route === 'reserve' || route === 'world' ? 0 : 2)); + if (route === 'reserve' || route === 'cross') assert.equal(p.forces[f.target], 2); + if (route === 'world' || route === 'homeworldForeign') + assert.deepEqual(game.homeworlds!.custody!.visitors[f.target][f.guild], { normal: 2, elite: 0 }); + costCustody(game, f, claim); + reject(game, f.holder, { type: 'semutaCommit', event }); + game = applyAction(game, f.guild, { type: 'endMovement' }); + assert.notEqual(game.active, f.guild); + } + }); + +for (const route of ['cross', 'return', 'homeworldReturn', 'homeworldForeign'] as SemutaGuildRoute[]) + void test(`recovering the cost cannot undo native ${route} permission denial or spend the unused shipment`, () => { + for (const claim of [false, true]) { + const f = createSemutaGuildTransport({ route, cancel: 'permission' }); + let game = applyAction(f.beforeCost, f.spender, f.cancel); + assert.equal(game.pendingTreacheryDiscard?.batch.cause, 'karama:guildTransportPermission'); + unpaid(game, f.beforeAction); + game = reactSemutaGuildTransport(game, claim); + unpaid(game, f.beforeAction); + costCustody(game, f, claim); + reject(game, f.guild, f.action); + // The original ordinary native alternative is still legal; recovering + // the denial cost creates neither permission nor another allowance. + game = applyAction(game, f.guild, { type: 'ship', territory: 'arrakeen', sector: 10, + amount: 1, elite: 0, allyPayment: 0 }); + if (game.decision?.kind === 'guildShipment') + game = applyAction(game, f.guild, { type: 'decision', allow: true }); + assert.equal(player(game).spice, 3); + assert.equal(player(game).reserves, 14); + assert.equal(player(game).forces['arrakeen:10'], 1); + assert.equal(player(game).shipped, true); + costCustody(game, f, claim); + } + }); + +void test('a Semuta-recovered cancellation cost does not finance the unfunded full-price reserve group', () => { + for (const claim of [false, true]) { + const f = createSemutaGuildTransport({ poor: true }); + const canceled = applyAction(f.beforeCost, f.spender, f.cancel); + unpaid(canceled, f.beforeAction); + let game = reactSemutaGuildTransport(canceled, claim); + unpaid(game, f.beforeAction); + assert.equal(player(game).spice, 2); + assert.equal(game.pendingShipment, null); + costCustody(game, f, claim); + game = applyAction(game, f.guild, { type: 'ship', territory: f.destination, + sector: f.sector, amount: 1, elite: 0, allyPayment: 0 }); + if (game.decision?.kind === 'guildShipment') + game = applyAction(game, f.guild, { type: 'decision', allow: true }); + assert.equal(player(game).spice, 0); + assert.equal(player(game).reserves, 14); + assert.equal(player(game).forces[f.target], 1); + costCustody(game, f, claim); + } +}); + +void test('Basic native rate cancellation uses the same real Semuta acquisition without inventing Advanced interception', () => { + const f = createSemutaGuildTransport({ advanced: false }); + let game = applyAction(f.beforeCost, f.spender, f.cancel); + assert.equal(game.pendingTreacheryDiscard?.batch.cause, 'karama:guildRate'); + unpaid(game, f.beforeAction); + game = reactSemutaGuildTransport(game, true); + assert.equal(player(game).spice, 0); + assert.equal(player(game).reserves, 13); + assert.equal(player(game).forces[f.target], 2); + assert.equal(player(game).shipped, true); + costCustody(game, f, true); +});