diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index e91ef94b..5cfe88f4 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,25 @@ 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 keeps the purchased price, not a duplicated refund — 16 October.** +Printed shipment-price costs now offer their exact fresh card before the +same original price activates. Recovery changes physical custody, not the +beneficiary/creator or bank invoice. A claimed cost no longer qualifies for +the provisional special-stop refund—even if its new holder later discards +that very card to pay the stop. Unclaimed original refunds remain unchanged. + +Affected eighteen-file234/234 passes, including eight new cases. Fourteen +original-action runtime/policy paths and two explicitly controlled extra-card +refund probes pass. Four actual390px journeys exercise ordinary bank3, +same-card reserve stop, typed bank2 after all-seat decline, and same-card +typed stop followed by ordinary movement. No overflow/browser/application +error; two transient tool-only stale-node errors were handled without replay. +No real room, worker/D1, human server, deployment or mode gate changed. +Converted/special-cost producers and final assurance remain separate. +[Canonical source, policy and proof](SEMUTA_SOURCE_UPDATE_20260907.md#shipment-price-cost-and-claimed-card-refund--16-october-2026). +Final types/lint and production build pass with existing warnings. +Current source links and all five still-partial Semuta facets resolve. + **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. diff --git a/docs/GUILD_HOMEWORLD_TRANSPORT_RULES.md b/docs/GUILD_HOMEWORLD_TRANSPORT_RULES.md index f8aea158..52838a57 100644 --- a/docs/GUILD_HOMEWORLD_TRANSPORT_RULES.md +++ b/docs/GUILD_HOMEWORLD_TRANSPORT_RULES.md @@ -16,9 +16,11 @@ any player’s Force Shipment at half normal price, paid to bank. Both typed quotes, actual controls and minimal budgets now honor that independent price and do not stack it with explicit Guild Nexus. Whole donor/own payment goes to bank, with no ordinary Guild receipt. Original manual/automatic -interception and exact typed custody remain. Special-stop returns the -original purchase card under the existing provisional reserve-stop policy; -actual completion consumes its grant and preserves movement. +interception and exact typed custody remain. Special-stop returns only an +unclaimed original purchase cost under the existing provisional policy; +actual completion consumes its grant and preserves movement. A cost +[recovered through Semuta](SEMUTA_SOURCE_UPDATE_20260907.md#shipment-price-cost-and-claimed-card-refund--16-october-2026) +keeps the price effect but cannot also be refunded, even after rediscard. Actual390px original controls gift the auctioned Ecaz card to Moritani, ship three from Grumman to Ecaz for two bank spice, preserve Guild6 and diff --git a/docs/GUILD_RATE_KARAMA_RULES.md b/docs/GUILD_RATE_KARAMA_RULES.md index 4763b4c2..b09ad366 100644 --- a/docs/GUILD_RATE_KARAMA_RULES.md +++ b/docs/GUILD_RATE_KARAMA_RULES.md @@ -2,6 +2,18 @@ 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. +## Recovered purchase cost and stop refund — 16 October 2026 + +Semuta now offers an original printed shipment-price activation cost. +Recovery changes physical card custody, not the grant's original creator, +beneficiary, half-normal price or bank-only routing. The selected extension +to the older provisional special-stop refund policy preserves that grant +but ends refund eligibility for the claimed cost, even if later rediscarded. +Thus the same recovered card can legally pay Guild's stop without also being +returned to its former owner. Unclaimed costs retain the existing refund. +[Canonical policy and original/controlled proof](SEMUTA_SOURCE_UPDATE_20260907.md#shipment-price-cost-and-claimed-card-refund--16-october-2026). + + ## Native price with Richese Secret Ally — 15 October 2026 The original Guild holder's Richese source now keeps its one-counter bill diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 84d5cf9d..0584b9cc 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,59 @@ # Dune implementation status +## 16 October 2026 — Semuta preserves purchased price and claimed-cost custody + +Added `karamaShipmentPriceDiscard` for a printed ordinary Karama's original +shipment-price cost. The neutral fresh-discard offer precedes activation +of the same original beneficiary/creator/card grant. Claim or decline neither +repeats purchase nor transfers the price benefit to the physical claimant. +Its half-normal tariff and entire bank invoice remain independent. + +Selected nonduplication composition extends the prior provisional Guild-stop +refund policy: a claimed cost records `costClaimed: true` and cannot also be +refunded to its former owner, even after a later rediscard. Guild may +actually spend that very recovered physical card for its special stop. +The stopped shipper retains resources and movement while shipment is used; +no card is duplicated or pulled back from the claimant. Unclaimed costs +retain the existing exact original refund. This is a product composition, +not a newly found publisher worked example. + +Original setup, paid public Semuta cache purchase and paid normal Karama +auction supply both physical cards. Real progression forks from the completed +auction to Atreides' original unused shipment. No played hand, force, wallet, +phase or decision is assigned in the original sources. + +Affected eighteen-file **234/234** passes, including eight new behavior cases. +Fourteen original-action runtime/policy paths cover Basic/Advanced reserve +and typed worlds, claim/decline, two same-card stops and all four original +profiles. Two additional refund probes explicitly conserve a second stop +card into Guild custody; those acquisitions are controlled, not original. +No strategy tuning or calibration was added. + +Four actual styled390×844 GameTable journeys start before activation. +Original Emperor→Atreides price, Guild cost claim and reserve shipment pay +bank3 once while Guild retains the recovered card. Spending that exact card +for native stop instead leaves Atreides10 spice/10 reserves and both used +cards singly discarded, without a former-owner refund. Typed all-seat +decline keeps Semuta held and delivers Caladan3→Kaitain for bank2. Typed +claim/same-card stop moves no funds/forces and permits the original ordinary +Arrakeen→Imperial Basin move. Original automatic/manual interception stays +source-dependent, not synthesized. +Viewport/scroll width390, no browser/application error; two transient +stale-node tool errors were resolved from fresh evidence without replay. + +Only an owned memory-only host started/stopped. No worker, D1, authenticated +saved room, human server, deployed revision or public gate was exercised +or changed. Outside-Git original/API/table JSON and four initial scenes +remain under `/tmp/dune-semuta-price-*20261016*`. Converted and other +special-Karama discard producers, complete combinations and final assurance +remain unfinished; the full goal stays active. +[Source and selected policy](SEMUTA_SOURCE_UPDATE_20260907.md#shipment-price-cost-and-claimed-card-refund--16-october-2026), +[continuation](SEMUTA_DISCARD_CONTINUATIONS.md#shipment-price-activation-cost--16-october-2026). +Final `npm run check:quick` and production build pass with existing +module-register, large-chunk and Unknown-route warnings. Nine current +local links and52 Semuta evidence paths resolve; all five facets remain +Partial without complete-mode or deployed assurance. + ## 16 October 2026 — Semuta retains native Guild permission/rate cancellation Added the dedicated `karamaShipmentDiscard` family using the existing diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 14bbc12d..6ae065d2 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -672,8 +672,9 @@ 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) | +| 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 uses the distinct continuation below. | [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 retains the existing provisional refund only for an unclaimed original cost; Semuta recovery preserves the price but ends that refund eligibility, even after rediscard. Immune Junction keeps its separate source choice. | [Connected typed routes](GUILD_HOMEWORLD_TRANSPORT_RULES.md#separate-permission-and-purchased-price--15-october-2026), [claimed-cost composition](SEMUTA_SOURCE_UPDATE_20260907.md#shipment-price-cost-and-claimed-card-refund--16-october-2026) | +| Semuta on shipment-price activation | Claim/decline of the exact printed activation cost creates the same original half-normal bank-paid grant once. Physical recovery does not change its beneficiary/creator. Under the selected nonduplication extension to provisional stop refunds, a claimed cost is not refundable even if the new holder rediscarded it; Guild may use that same card for its actual stop without returning it to the former owner. Unclaimed refunds remain. This is product composition, not a publisher worked example. | [Source, policy and scoped proof](SEMUTA_SOURCE_UPDATE_20260907.md#shipment-price-cost-and-claimed-card-refund--16-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) | | 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) | diff --git a/docs/SEMUTA_DISCARD_CONTINUATIONS.md b/docs/SEMUTA_DISCARD_CONTINUATIONS.md index 09341a97..73464774 100644 --- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md +++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md @@ -2,6 +2,28 @@ 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. +## Shipment-price activation cost — 16 October 2026 + +`karamaShipmentPriceDiscard` offers the exact public printed cost before +activating its original other-player price. The unused opportunity, +beneficiary, creator and cost identity stay bound. Claim/decline creates the +same grant once; no shipping payment or counter departure occurs at this +boundary. The physical claimant does not become the benefit's creator. + +After a claim, the grant records `costClaimed: true`. The selected extension +to the provisional Guild-stop refund policy does not return a claimed cost, +even if its new holder later discards it again as the special-stop cost. +The same physical card can pay that new legal use, but is not also refunded +to its former owner. An unclaimed cost retains its older exact-card refund. +This is source/nonduplication composition, not a publisher worked example. + +[Current source and scoped proof](SEMUTA_SOURCE_UPDATE_20260907.md#shipment-price-cost-and-claimed-card-refund--16-october-2026) +records234 affected cases, fourteen original runtime/policy paths, two +controlled extra-card refund probes and four actual mobile journeys. +Converted costs and other special-Karama disposal sources remain unfinished; +full Semuta/module certification and final assurance are not implied. + + ## Native Guild cancellation cost family — 16 October 2026 `karamaShipmentDiscard` now suspends a printed cancellation cost for original diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index 0395ba55..d214585e 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -2,6 +2,52 @@ 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. +## Shipment-price cost and claimed-card refund — 16 October 2026 + +An ordinary **printed Karama** spent for the active player's shipment price +now offers its exact public cost through `karamaShipmentPriceDiscard`. +The dedicated continuation retains the original unused opportunity, +beneficiary, creator and physical card. Claim or all-seat decline activates +that same half-normal, bank-paid benefit once; it does not transfer the +benefit to the physical card's new holder or repeat the purchase. + +**Selected nonduplication composition, not a publisher worked example:** +Semuta recovery ends the older provisional special-stop refund eligibility +for that cost, not the price benefit. Internal `costClaimed: true` retains +that fact even if the card is subsequently discarded again. Guild may +legally spend the **very same recovered card** for its original special +stop, but cannot also refund that later discard to the former cost owner. +Unclaimed costs keep the existing exact-card refund policy; completed +shipments still consume only their original grant. + +Affected eighteen-file **234/234** passes, including eight new behavior +cases. Fourteen original-action runtime/policy paths cover Basic/Advanced +reserve and typed world prices, claim/decline, both same-card stops and all +four existing profiles. Two additional refund probes explicitly conserve a +second stop card into Guild custody after an actual original decline; they +are **controlled acquisition**, not original-action acquisition proof. + +Four actual styled390×844 GameTable journeys start before price activation: +Emperor's real original card grants Atreides bank3 while Guild holds its +recovered cost; the same recovered card instead stops that reserve shipment +without refund; typed all-seat decline delivers Caladan3→Kaitain for bank2; +and typed claim/same-card stop preserves both native pools/wallets, followed +by an actual ordinary Atreides move. Original source-dependent manual/ +automatic interception remains. Viewport/scroll width390 and no browser/ +application errors; two transient tool-only stale-node errors were handled +with fresh evidence and no replay. + +Only an owned memory-only loopback host ran. No worker, D1, authenticated +saved room, human server, deployment or mode gate changed. Original/API/table +JSON and four initial scenes remain outside Git under +`/tmp/dune-semuta-price-*20261016*`. Converted costs, other special-Karama +discard producers, full combinations and final assurance remain separate. +[Continuation contract](SEMUTA_DISCARD_CONTINUATIONS.md#shipment-price-activation-cost--16-october-2026). +Final `npm run check:quick` (types/lint) and production build pass; existing +module-register, large-chunk and Unknown-route warnings remain. Nine current +local links and52 Semuta evidence paths resolve; all five facets stay Partial. + + ## Native Guild permission and rate costs — 16 October 2026 Printed Karama cancellation of native `guildTransportPermission` or diff --git a/game/engine.ts b/game/engine.ts index 1ccce9d3..c6662450 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1401,6 +1401,14 @@ export type Game = { resume: NonNullable['resume']; stateSignature: string; } + | { + kind: 'karamaShipmentPriceDiscard'; + player: string; + card: string; + use: Extract; + resume: NonNullable['resume']; + stateSignature: string; + } | { kind: 'karamaPurchaseDiscard' | 'karamaPaymentDiscard'; player: string; @@ -2155,7 +2163,11 @@ export type Game = { protected?: boolean; choamProtected?: string[]; } | null; - karamaShipping: { player: string; owner: string; card?: string } | null; + karamaShipping: { + player: string; owner: string; card?: string; + /** A recovered cost cannot be refunded again, even if later discarded. */ + costClaimed?: true; + } | null; stormDialers: string[]; lastBattle: string[]; homeworldBattleLoss?: { @@ -5532,6 +5544,17 @@ function karamaAuctionDiscardSignature(g: Game, c: KaramaAuctionDiscardContinuat })), }); } +type KaramaShipmentPriceDiscardContinuation = Extract< + NonNullable['continuation'], + { kind: 'karamaShipmentPriceDiscard' } +>; +function karamaShipmentPriceDiscardSignature(g: Game, c: KaramaShipmentPriceDiscardContinuation) { + return JSON.stringify({ + player: c.player, card: c.card, use: c.use, + parent: nullentropyParentSignature(g, c.resume, `karama:shipment:${c.card}`), + opportunity: karamaOpportunitySignature(g, { owner: c.player, use: c.use }), + }); +} type ChoamSaleDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'choamSaleDiscard' } @@ -6363,6 +6386,21 @@ function treacheryDiscardIntegrity(g: Game) { throw error; } } + } else if (continuation?.kind === 'karamaShipmentPriceDiscard') { + const c = continuation, entry = batch.entries[0]; + requireRule( + g.phase === 5 && !g.response && !g.decision && !g.pendingKarama && + !g.phaseOpening && !c.resume.response && !c.resume.decision && + !c.resume.pendingKarama && !c.resume.phaseOpening && + c.use.kind === 'shipment' && c.use.card === c.card && + batch.cause === 'karama:shipment' && batch.entries.length === 1 && + entry.publicFace && entry.discardedBy === c.player && + entry.card.id === c.card && entry.card.effect === 'karama' && + g.players.some(p => p.id === c.player) && + c.stateSignature === karamaShipmentPriceDiscardSignature(g, c), + 'The spent Karama no longer matches its original shipment-price purchase.', + ); + validateKaramaUse(g, getPlayer(g, c.player), c.use); } else if (continuation?.kind === 'karamaPurchaseDiscard' || continuation?.kind === 'karamaPaymentDiscard') { const c = continuation, entry = batch.entries[0]; @@ -7184,6 +7222,7 @@ function semutaOfferSupported( continuation.kind !== 'karamaEmperorShareDiscard' && continuation.kind !== 'karamaIncomeDiscard' && continuation.kind !== 'karamaShipmentDiscard' && + continuation.kind !== 'karamaShipmentPriceDiscard' && continuation.kind !== 'kaitainDiscard' && continuation.kind !== 'tupileCleanupDiscard' && continuation.kind !== 'karamaPurchaseDiscard' && @@ -7217,6 +7256,7 @@ function semutaOfferSupported( continuation.kind !== 'tupileCleanupDiscard' && continuation.kind !== 'karamaIncomeDiscard' && continuation.kind !== 'karamaShipmentDiscard' && + continuation.kind !== 'karamaShipmentPriceDiscard' && continuation.kind !== 'ordinaryCardDiscard' && continuation.kind !== 'nullentropyDiscard' && continuation.kind !== 'distransDiscard' && @@ -7616,6 +7656,14 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string const battle = g.battle!; if (battle.preLeader && !battle.preLeader.closed) battle.preLeader.ready = battle.preLeader.ready.filter(id => id !== next.player); + } else if (next.kind === 'karamaShipmentPriceDiscard') { + g.response = next.resume.response; + g.decision = next.resume.decision; + g.pendingKarama = next.resume.pendingKarama; + g.phaseOpening = next.resume.phaseOpening; + const claimed = !g.discard.some(card => card.id === next.card); + completeKarama(g, getPlayer(g, next.player), next.use); + if (claimed) g.karamaShipping!.costClaimed = true; } else if (next.kind === 'karamaCharityDiscard' || next.kind === 'karamaInflationDiscard' || next.kind === 'karamaBgCharityDiscard' || @@ -14262,6 +14310,7 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered requireRule(!g.pendingKull || promiseProven, 'Kull preview cannot accept this unproven Truthtrance counter continuation.'); if (!kullOffered && beginKullAttempt(g, p, card, { kind: 'ordinary', use }, promiseProven)) return; + if (use.kind === 'shipment') use = { ...use, card: card.id }; const used = discard(g, p, card.id); const guildShipmentDiscard = g.semutaPreview && card.effect === 'karama' && use.kind === 'cancel' && semutaGuildShipmentKaramaResponse(g, use.response); @@ -14314,7 +14363,20 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered return; } } - if (use.kind === 'shipment') use = { ...use, card: card.id }; + if (g.semutaPreview && card.effect === 'karama' && use.kind === 'shipment') { + const continuation: KaramaShipmentPriceDiscardContinuation = { + kind: 'karamaShipmentPriceDiscard', player: p.id, card: used.id, use, + resume: { response: g.response, decision: g.decision, + pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening }, + stateSignature: '', + }; + const entries = [{ card: used, discardedBy: p.id, publicFace: true }]; + if (semutaOfferSupported(g, continuation, entries)) { + continuation.stateSignature = karamaShipmentPriceDiscardSignature(g, continuation); + stageTreacheryDiscard(g, 'karama:shipment', entries, continuation); + return; + } + } if (card.kind === 'worthless') { requireRule(!g.pendingKarama, 'A Worthless conversion is already pending.'); g.pendingKarama = { owner: p.id, use }; @@ -28769,11 +28831,11 @@ function specialKaramaAction(intent: SpecialKaramaIntent): Action { } } -/** Existing provisional special-stop policy: return only the original purchased card. */ +/** Existing provisional stop refund applies only to the unclaimed original cost. */ function refundStoppedShipmentKarama(g: Game, shipper: Player) { const benefit = g.karamaShipping; if (benefit?.player !== shipper.id) return; - if (benefit.card) { + if (benefit.card && !benefit.costClaimed) { const index = g.discard.findIndex(card => card.id === benefit.card); if (index >= 0) getPlayer(g, benefit.owner).hand.push(...g.discard.splice(index, 1)); diff --git a/game/reference.ts b/game/reference.ts index a417dae9..d8e007f6 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1201,7 +1201,8 @@ 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.', + '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.', + 'A printed Karama buying another active player’s shipment price also offers its exact fresh cost before that same original grant activates. Claim or Continue preserves the original beneficiary, creator and half-normal bank-paid price. Physical recovery ends the provisional stop-refund eligibility for that cost, even if the claimant later discards it again. Guild can spend that very recovered card for an actual special stop without also refunding it to the former owner. Unclaimed original costs retain their older refund; this is selected source composition, not a publisher worked example.', '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.', @@ -1226,31 +1227,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. 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.', + '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. Printed shipment-price activation now has its own continuation: claim/decline creates the same original price once. Recovered physical cost retains the beneficiary/creator but loses provisional refund eligibility even after rediscard; the same recovered card can pay a real stop without being returned to its former owner.', }, { 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. 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.', + '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. Earlier four original-action390px native 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. Four new original-action390px journeys start at actual price activation: bank3 allowance while Guild holds the cost, same-card reserve stop, typed bank2 after all-seat decline, and same-card typed stop followed by real ordinary movement. No overflow/browser/application errors; two tool-only stale-node races were resolved without 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. 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.', + '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. All four original-state profiles claim the price cost; a subsequent real owner action executes the retained bank-paid shipment. This is legal source participation, not a new strategy preference 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 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.', + '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. Printed price-cost activation is connected below; converted and special costs remain separate. The printed price-cost continuation and claimed-cost nonduplication extension are explicit. Unclaimed original refunds remain provisional and unchanged; recovery does not transfer or repeat the price benefit. Converted and other special-Karama discard sources remain 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. 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.', + '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. Earlier 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. New eighteen-file234/234 passes, including8 new cases. Fourteen original-action runtime/policy paths and four actual original-action390px journeys pass; two explicitly controlled extra-stop-card acquisitions prove unclaimed refunds. The original same-card stop uses no extra card or staged acquisition. No worker/D1/saved/deployed or complete-mode assurance.', evidence: [ 'tests/semuta-engine.test.ts', 'tests/semuta-recovery.test.ts', @@ -1303,6 +1304,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'tests/semuta-homeworld-capture-karama.test.ts', 'tests/semuta-homeworld-choam-battle-karama.test.ts', 'tests/semuta-guild-transport.test.ts', + 'tests/semuta-shipment-price.test.ts', ], }, ], @@ -2565,7 +2567,7 @@ export const RULE_TOPICS: RuleTopic[] = [ '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.', 'Native Guild cross/return permission has its own unpaid counter before price. Preventing it keeps a legal different shipment/movement without removing native half price. The conservative provisional scope is that original opportunity, not a separately signed Cunning second opportunity; immune Junction and common world-to-foreign-world permission remain independent. Native rate cancellation bills the same paid units and original approved split. Free counters stay free and own/allied No-Field prices one marker. Supplier whole-fee1 requires a new offer/consent before becoming2.', 'A native Guild holder of Richese Secret Ally still ships up to five real forces at one-counter cost. Canceling its native rate changes sand1 to2, not five physical forces to ten paid units. If full2 is unaffordable, the declared Nexus stays spent without refund or reusable coupon while ordinary shipment remains available; this is the selected affordability composition. A still-held card uses the corrected full price. An independent purchased Karama price stays separate.', - 'Use a held Karama to purchase the active player’s Force Shipment at half normal price, rounded up, paid entirely to bank. Original typed World routes retain this independent source without new route permission or Nexus stacking. Completion consumes only its grant; original Guild special-stop provisionally returns the exact purchase card to its owner and preserves resources/movement while using shipment. Immune Junction instead keeps its offered tariff and normal payees. Accepting it explicitly acknowledges that the separate purchased price ends unused, without a card refund, only upon actual delivery; the ordinary purchased route remains an alternative.', + 'Use a held Karama to purchase the active player’s Force Shipment at half normal price, rounded up, paid entirely to bank. Original typed World routes retain this independent source without new permission or Nexus stacking. Semuta can recover the printed cost without changing its original price beneficiary or creator; a recovered cost cannot also receive the provisional Guild-stop refund, even after rediscard. Unclaimed original costs keep that existing refund. Completion consumes only the grant. Immune Junction keeps its original tariff/payees; accepting it explicitly ends the separate purchased price unused without refund, only upon delivery.', '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.', 'Atreides full-plan inspection, Emperor revival, Fremen’s summoned worm, Harkonnen hand exchange the Guild shipment stop and Tleilaxu revival prevention are implemented as once-per-game uses. Other cancellations and special Karamas are still being implemented.', diff --git a/tests/fixture-semuta-shipment-price.ts b/tests/fixture-semuta-shipment-price.ts new file mode 100644 index 00000000..5a33909a --- /dev/null +++ b/tests/fixture-semuta-shipment-price.ts @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import { applyAction, 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 { createSemutaGuildTransport } from './fixture-semuta-guild-transport'; +import { nextSemutaGuildIncomeNativeStep } from './fixture-semuta-guild-income'; + +export function createSemutaShipmentPrice(options: { advanced?: boolean; homeworlds?: boolean } = {}) { + const original = createSemutaGuildTransport({ advanced: options.advanced, + route: options.homeworlds ? 'world' : 'reserve' }); + let game = structuredClone(original.boughtKarama); + const actions: { actor: string; action: Action }[] = []; + for (let n = 0; !(game.phase === 5 && game.active === 'a' && !game.response && + !game.decision && !game.phaseOpening && !game.pendingTreacheryDiscard) && n < 1600; n++) { + const next = nextSemutaGuildIncomeNativeStep(game); actions.push(next); + game = applyAction(game, next.actor, next.action); + } + assert.equal(game.phase, 5); assert.equal(game.active, 'a'); + assert.ok(game.players.find(p => p.id === 'e')!.hand.some(card => card.id === original.karama)); + assert.ok(game.players.find(p => p.id === 'g')!.hand.some(card => card.id === original.semuta)); + const beforeAction = structuredClone(game); + const action: Action = { type: 'card', card: original.karama, mode: 'shipment', target: 'a' }; + game = applyAction(game, 'e', action); + assert.equal(game.pendingTreacheryDiscard?.batch.cause, 'karama:shipment'); + assert.equal(game.karamaShipping, null); + return { original, actions, beforeAction, action, game, + owner: 'e', beneficiary: 'a', guild: 'g', holder: 'g', + karama: original.karama, semuta: original.semuta, + provenance: [...original.provenance, + 'Fork from actual paid Karama purchase; real native progression to the original Atreides shipment turn.', + 'Emperor actually spends its own printed card for the other active player; no played state assigned.'] }; +} + +export function reactSemutaShipmentPrice(state: Game, claim: boolean) { + const pending = state.pendingTreacheryDiscard; assert.ok(pending); + const reaction = pending.reaction; assert.ok(reaction?.stage === 'offer'); + const event = pending.batch.event; + let game = state; + 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; +} + +export function semutaPurchasedShipment(game: Game, homeworlds = false) { + if (homeworlds) { + const quote = homeworldShipmentChoice(viewGame(game, 'a'), 'homeworld:emperor', + { 'homeworld:atreides': { normal: 3, elite: 0 } }); + assert.ok(quote.action, quote.blocked ?? undefined); + return { action: quote.action, target: 'homeworld:emperor', amount: 3, cost: 2 }; + } + 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 action: Action = { type: 'ship', territory: destination.id, sector, + amount: 3, elite: 0, allyPayment: 0 }; + return { action, target: `${destination.id}:${sector}`, amount: 3, cost: 3 }; +} + +export function allowSemutaPurchasedShipment(state: Game) { + let game = state; + if (game.decision?.kind === 'guildShipment') + game = applyAction(game, 'g', { type: 'decision', allow: true }); + else if (game.decision?.kind === 'homeworldShipmentGuild') + game = applyAction(game, 'g', { type: 'decision', event: game.decision.event, allow: true }); + return game; +} diff --git a/tests/semuta-shipment-price.test.ts b/tests/semuta-shipment-price.test.ts new file mode 100644 index 00000000..307e1989 --- /dev/null +++ b/tests/semuta-shipment-price.test.ts @@ -0,0 +1,110 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { + createSemutaShipmentPrice, reactSemutaShipmentPrice, + semutaPurchasedShipment, allowSemutaPurchasedShipment, +} from './fixture-semuta-shipment-price'; + +const player = (game: Game, actor: string) => game.players.find(p => p.id === actor)!; +function reject(game: Game, actor: string, action: Action) { + const before = structuredClone(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before); +} + +for (const advanced of [false, true]) for (const homeworlds of [false, true]) + void test(`${advanced ? 'Advanced' : 'Basic'} ${homeworlds ? 'typed world' : 'reserve'} price survives Semuta claim or decline and pays only bank once`, () => { + for (const claim of [false, true]) { + const f = createSemutaShipmentPrice({ advanced, homeworlds }); + const beneficiary = player(f.game, f.beneficiary); + const spice = beneficiary.spice, reserves = beneficiary.reserves; + const guildSpice = player(f.game, f.guild).spice; + assert.equal(f.game.karamaShipping, null); + assert.equal(beneficiary.shipped, false); + reject(f.game, f.beneficiary, { type: 'endMovement' }); + let game = reactSemutaShipmentPrice(f.game, claim); + assert.equal(viewGame(game, f.beneficiary).karamaShipping!.owner, f.owner); + assert.equal(player(game, f.holder).hand.some(card => card.id === f.karama), claim); + assert.equal(player(game, f.holder).hand.some(card => card.id === f.semuta), !claim); + assert.equal(player(game, f.beneficiary).spice, spice); + assert.equal(player(game, f.beneficiary).reserves, reserves); + const shipment = semutaPurchasedShipment(game, homeworlds); + game = applyAction(game, f.beneficiary, shipment.action); + game = allowSemutaPurchasedShipment(game); + assert.equal(player(game, f.beneficiary).spice, spice - shipment.cost); + assert.equal(player(game, f.beneficiary).reserves, reserves - 3); + assert.equal(player(game, f.guild).spice, guildSpice); + assert.equal(player(game, f.beneficiary).shipped, true); + assert.equal(player(game, f.beneficiary).moved, 0); + if (homeworlds) assert.deepEqual(game.homeworlds!.custody!.visitors[shipment.target][f.beneficiary], { normal: 3, elite: 0 }); + else assert.equal(player(game, f.beneficiary).forces[shipment.target], 3); + assert.equal(game.karamaShipping, null); + assert.equal(player(game, f.holder).hand.some(card => card.id === f.karama), claim); + assert.equal(game.discard.filter(card => card.id === f.semuta).length, claim ? 1 : 0); + reject(game, f.beneficiary, shipment.action); + } + }); + +for (const homeworlds of [false, true]) + void test(`the SAME recovered price card can stop ${homeworlds ? 'typed world' : 'reserve'} shipment without being refunded to its former owner`, () => { + const f = createSemutaShipmentPrice({ homeworlds }); + let game = reactSemutaShipmentPrice(f.game, true); + const original = structuredClone(player(game, f.beneficiary)); + const shipment = semutaPurchasedShipment(game, homeworlds); + game = applyAction(game, f.beneficiary, shipment.action); + game = applyAction(game, f.guild, { type: 'card', card: f.karama, mode: 'special' }); + const shipper = player(game, f.beneficiary); + assert.equal(shipper.spice, original.spice); + assert.equal(shipper.reserves, original.reserves); + assert.deepEqual(shipper.forces, original.forces); + assert.equal(shipper.shipped, true); + assert.equal(shipper.moved, 0); + assert.equal(player(game, f.owner).hand.some(card => card.id === f.karama), false); + assert.equal(player(game, f.guild).hand.some(card => card.id === f.karama), false); + assert.equal(game.discard.filter(card => card.id === f.karama).length, 1); + assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1); + assert.equal(game.karamaShipping, null); + if (homeworlds) assert.equal(game.homeworlds!.custody!.visitors[shipment.target]?.[f.beneficiary], undefined); + else assert.equal(shipper.forces[shipment.target] ?? 0, 0); + game = applyAction(game, f.beneficiary, { type: 'endMovement' }); + assert.notEqual(game.active, f.beneficiary); + }); + +void test('declining the price cost retains its existing exact-card refund when another physical card pays the stop', () => { + for (const homeworlds of [false, true]) { + const f = createSemutaShipmentPrice({ homeworlds }); + let game = reactSemutaShipmentPrice(f.game, false); + // CONTROLLED second-card acquisition, not an original-action producer. + // The original price and actual Guild stop still use distinct physical IDs. + const at = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(at >= 0); + const stop = game.deck.splice(at, 1)[0]; player(game, f.guild).hand.push(stop); + const before = structuredClone(player(game, f.beneficiary)); + const shipment = semutaPurchasedShipment(game, homeworlds); + game = applyAction(game, f.beneficiary, shipment.action); + game = applyAction(game, f.guild, { type: 'card', card: stop.id, mode: 'special' }); + assert.equal(player(game, f.beneficiary).spice, before.spice); + assert.equal(player(game, f.beneficiary).reserves, before.reserves); + assert.equal(player(game, f.beneficiary).shipped, true); + assert.equal(player(game, f.owner).hand.filter(card => card.id === f.karama).length, 1); + assert.equal(game.discard.some(card => card.id === f.karama), false); + assert.equal(game.discard.filter(card => card.id === stop.id).length, 1); + assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta)); + assert.equal(game.karamaShipping, null); + } +}); + +void test('wrong claimant and stale price-cost events cannot activate, duplicate or replace the original benefit', () => { + const f = createSemutaShipmentPrice(); + const event = f.game.pendingTreacheryDiscard!.batch.event; + reject(f.game, f.owner, { type: 'semutaCommit', event }); + reject(f.game, f.holder, { type: 'semutaCommit', event: 'old' }); + reject(f.game, f.owner, f.action); + const game = reactSemutaShipmentPrice(f.game, true); + reject(game, f.holder, { type: 'semutaCommit', event }); + reject(game, f.holder, { type: 'card', card: f.karama, mode: 'shipment', target: f.beneficiary }); + const shipment = semutaPurchasedShipment(game); + const done = allowSemutaPurchasedShipment(applyAction(game, f.beneficiary, shipment.action)); + assert.equal(player(done, f.beneficiary).spice, 7); + assert.equal(player(done, f.beneficiary).forces[shipment.target], 3); +});