From 96de96262279b705292d8550514380157fea262a Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:51:41 +1100 Subject: [PATCH] Compose purchased Karama rates with Richese Cunning --- components/richese-no-field.tsx | 8 +- docs/CURRENT_STATUS.md | 27 +++ docs/GUILD_RATE_KARAMA_RULES.md | 12 + docs/IMPLEMENTATION_STATUS.md | 49 ++++ docs/NEXUS_RICHESE_RULES.md | 61 ++++- docs/RULE_DECISIONS.md | 3 +- game/bots.ts | 2 +- game/engine.ts | 25 +- game/homeworld-payment-options.ts | 2 +- game/reference.ts | 9 +- game/richese-cunning-rate.ts | 67 ++++++ .../fixture-richese-cunning-purchased-rate.ts | 79 +++++++ tests/fixture-richese-homeworld-cunning.ts | 6 +- tests/fixture-richese-homeworld-shipment.ts | 25 +- ...ese-cunning-purchased-rate-runtime.test.ts | 220 ++++++++++++++++++ tests/richese-cunning-rate.test.ts | 84 +++++++ 16 files changed, 660 insertions(+), 19 deletions(-) create mode 100644 game/richese-cunning-rate.ts create mode 100644 tests/fixture-richese-cunning-purchased-rate.ts create mode 100644 tests/richese-cunning-purchased-rate-runtime.test.ts create mode 100644 tests/richese-cunning-rate.test.ts diff --git a/components/richese-no-field.tsx b/components/richese-no-field.tsx index 0bc30ca7..a91a95c4 100644 --- a/components/richese-no-field.tsx +++ b/components/richese-no-field.tsx @@ -194,7 +194,8 @@ export function RicheseNoFieldControls({ pairGroup = quoteRicheseHomeworldPair({ enabled: info.homeworldForcesEnabled, reserves: me.reserves, amount: reserveForces, revealedValue: pairRevealed?.value ?? 0 }); } catch (error) { pairProblems.push(error instanceof Error ? error.message : 'Choose available paid reserve forces.'); } - const pairCost = reserveShipmentCost({ faction: me.faction, halfRate: false }, selectedTerritory.type, pairGroup?.priced ?? 1); + const pairPurchasedRate = game.karamaShipping?.player === me.id; + const pairCost = reserveShipmentCost({ faction: me.faction, halfRate: pairPurchasedRate }, selectedTerritory.type, pairGroup?.priced ?? 1); if (pairCost > (me.spice ?? 0)) pairProblems.push(`This Cunning group needs ${pairCost} own spice.`); if (nexus?.blocked) pairProblems.push(nexus.blocked); if (!pairConcealed || !pairRevealed) @@ -362,6 +363,11 @@ export function RicheseNoFieldControls({ applies to this pair. Smuggler is a separate selection for the ordinary single-marker route.

+ {pairPurchasedRate &&

+ Your purchased Karama rate halves this combined invoice, + rounded up once. Pay the Spice Bank. The original rate remains + available if the No-Field counter prevents this declaration. +

} diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index e246bf1a..f0c23897 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,33 @@ open mode gates. ## Current checkpoint and work +**Purchased Karama rate with native Richese Cunning — 25 October. Prototyped / Partial.** +The original independent rate now halves Cunning's one billable pair unit plus +any High paid companions, rounded once and paid to the bank. The original +beneficiary/owner/activation card stays bound through responses even when city +rounding leaves the same fee. Live claimed-cost history remains authoritative. +No-Field prevention preserves the unused rate and spends the declared Nexus; +success consumes the grant. Advanced Guild stopping retains its existing +provisional exact-card refund and consumed-shipment outcome. Persistent controls, +minimal policies and the shared payment filter use the same quote. +[Source, policy and limits](NEXUS_RICHESE_RULES.md#purchased-karama-rate-with-native-cunning--25-october-2026). + +Disjoint helper/native-fixture work, bounded review and root integration pass +**20 files, 168/168**, types/lint/build and four native selector-callback/engine/ +GameTable SSR journeys. Actual four-seat Basic 126/Advanced 141 source actions +retain original setup, real alliance/draw/funding and Emperor's genuine Karama +activation. Eleven paid plus immediate five cost six, leaving four reserves +and six spice; Guild receives none. Original opposing Karama prevention retains +the rate for a real ordinary replacement; Advanced stopping returns only the +original unclaimed activation card while Nexus remains spent. Actual sixteen +paid plus capped four are affordable at nine, where full seventeen is not. +Zero/one-companion rounding and all four minimal policies pass. Explicitly +controlled metadata cases cover lost/replaced sources and claimed-cost refund; +no original card-recovery programme is claimed. Fixture type feedback is repaired. +No played source hand/wallet/phase/alliance/forces are installed. Wider modes, +browser/mobile/deployed and final assurance remain open. No saved-room/D1/server/ +reset/restart/deployment operation occurred; SSR is not hydration acceptance. + **High Richese with Nexus Cunning — 25 October. Prototyped / Partial.** Paid reserve forces now accompany the original two-token Cunning shipment. Its signed frame retains the explicit count and one invoice: reserve paid diff --git a/docs/GUILD_RATE_KARAMA_RULES.md b/docs/GUILD_RATE_KARAMA_RULES.md index b09ad366..bdab9068 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. +## Purchased rate with native Richese Cunning — 25 October 2026 + +The native unallied pair now retains an original independently purchased rate: +one billable pair unit plus any High paid counters, halved and rounded once, +entirely bank-paid. Identity is bound through the original pending frame even +when a stronghold's rounded fee stays one. Native No-Field prevention retains +the unused grant and spent Nexus; successful delivery consumes the grant. +Advanced Guild stopping retains the existing provisional exact-card refund +and live claimed-cost exclusion below. This extends a prototype boundary, +without supplying a new native Guild rate or resolving wider combinations. +[Canonical composition and evidence](NEXUS_RICHESE_RULES.md#purchased-karama-rate-with-native-cunning--25-october-2026). + ## Recovered purchase cost and stop refund — 16 October 2026 Semuta now offers an original printed shipment-price activation cost. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 9d37a7a3..c673931e 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,54 @@ # Dune implementation status +## 25 October 2026 — Purchased Karama rates compose with native Richese Cunning + +The native unallied pair now accepts its original independent purchased rate. +The server snapshots only beneficiary/owner/optional activation-card identity +inside the existing signed pair frame, prices one pair unit plus any High paid +companions at half-normal rates rounded once, and retains original bank routing. +Lost/replaced grants reject even at city price one. Mutable claimed-cost history +stays on the live grant, not its identity snapshot; historical outcomes need no +active rate or perpetual discard custody. Absent legacy sources stay absent. +No new client source fields, physical card or rate grant are introduced. + +Original No-Field prevention keeps the unused rate and ordinary shipment while +the Nexus stays spent. Successful delivery clears the rate once. Native Advanced +Guild stopping finishes the original history, consumes shipment and uses the +existing provisional refund: only the original unclaimed cost still in discard +returns to its owner. Claimed costs stay ineligible after rediscard. Persistent +pair guidance, minimal policies and shared Homeworld payment eligibility use +the same purchased tariff. Printed permission and this bounded composition are +explicit in the [canonical source contract](NEXUS_RICHESE_RULES.md#purchased-karama-rate-with-native-cunning--25-october-2026); +this is not a new publisher worked example or native Guild discount. + +Disjoint helper/native-fixture work, bounded read-only source/settlement review +and root integration pass **20 files, 168/168**, types/lint/build and four native +selector-callback/engine/GameTable SSR journeys. Actual four-seat Basic 126 and +Advanced 141 source actions earn real setup, Emperor/Atreides alliance, unallied +Richese draw, completed funding and Emperor's genuinely dealt Karama activation. +Eleven paid plus immediate five cost six, leave four reserves/six spice and award +Guild no income. Original Guild Karama prevention retains the rate for a real +ordinary replacement; actual Advanced special stopping returns only Emperor's +original unclaimed activation cost and spends Guild's own second original card. +An actual sixteen-paid/capped-four group costs nine with twelve available, while +full seventeen is unaffordable; the shared filter accepts only the purchased +version. Zero/one-companion city rounding and all four legal profiles pass. + +Six pure source cases and explicitly labelled controlled pending-grant/claimed- +cost metadata cases supplement native paths; they are not original Box/Semuta +recovery programmes. No source played hand/wallet/phase/alliance/forces is installed. +The initial quick check catches assertion-induced fixture type narrowing; its +one-line typed flag mutation repair passes the final frozen full selection. +Private proof/HTML: +`/home/quintine/.local/share/dune/checkpoints/20261025-richese-cunning-purchased-rate/`. +`frozen-type-fix/report.json` passes unchanged tree +`f289ed1aa3512bb2e79d4bff220aacafa5d6157e9651bcbf8e422e405684a9e7`, +2,809 files, base `61c2691`. Final prose-only evidence overlay is separately +checked without repeating unchanged runtime checks. Broader routes/modules, +full modes, browser/mobile/deployed and final assurance stay open. No saved-room, +D1, server, reset, restart or deployment operation occurred. SSR/callbacks do +not certify parent hydration. + ## 25 October 2026 — High Richese retains paid forces through Nexus Cunning The original two-token Cunning action now accepts explicit `noFieldReserves`. diff --git a/docs/NEXUS_RICHESE_RULES.md b/docs/NEXUS_RICHESE_RULES.md index bae62701..b6d8868a 100644 --- a/docs/NEXUS_RICHESE_RULES.md +++ b/docs/NEXUS_RICHESE_RULES.md @@ -2,6 +2,65 @@ Audited 10 September 2026, with a bounded Betrayal implementation contract added 30 September. This separates the ordinary Secret Ally shipment, native Cunning's two-marker operation and Betrayal's two auction outcomes. It does not certify full-family implementation or open the Nexus release gate. See [common Nexus rules](NEXUS_CARD_RULES.md), [auction custody and source boundaries](RICHESE_AUCTION_RULES.md), [No-Field custody](RICHESE_NO_FIELD_RULES.md), and [allied No-Field distinctions](RICHESE_ALLIED_SHIPMENT_AUDIT.md). +## Purchased Karama rate with native Cunning — 25 October 2026 + +**Prototyped / Partial.** The native unallied two-token Cunning shipment now +composes with an independently purchased ordinary Karama rate for that active +shipper. The supplied revision's physical page 16 permits purchasing any +player's Force Shipment at half-normal Guild rates, paid to the Spice Bank. +Cunning changes its billable quantity to one, not that independent rate. +With optional High reserve accompaniment, provisionally bill **one pair unit +plus the selected paid counters**, then halve and round up once. Immediate +faceup materialization adds no billable units. This is a bounded composition, +not a newly located publisher worked example or native Guild discount. + +The server captures the original rate's beneficiary, owner and optional +physical activation-card identity inside the pair's original signed frame. +An unchanged rounded fee cannot substitute a lost or replacement grant or +silently change the bank payee. Live claimed-cost history stays on the original +grant and may change independently; it is not reset from this identity snapshot. +Historical outcomes need no active grant or perpetual discard custody. Old +full-price frames retain their absent source rather than acquiring a new rate. + +Successful delivery pays the original combined invoice to the bank and consumes +the grant. Initial native No-Field prevention leaves that grant unused for a +legal ordinary replacement; tokens, physical counters and spice remain, the +shipment is unused and the already-declared Nexus remains spent. Advanced Guild +special stopping consumes the shipment and clears the grant without payment +or arrival. It retains the [existing provisional refund policy](GUILD_RATE_KARAMA_RULES.md#recovered-purchase-cost-and-stop-refund--16-october-2026): +return only the original unclaimed activation card still in discard to its +original owner. A claimed cost cannot refund even after rediscard. Never refund +the Nexus, invent another card or renew the purchased rate. + +Persistent pair controls show the combined price and bank-only purchased rate; +the minimal bot candidate and shared payment filter use that same tariff. +Original unallied, distinct-token/nonrepeat, unused-opportunity and parent +counter restrictions remain. A rate for another beneficiary is not borrowed. +Smuggler remains a separate single-marker selection. This section supersedes +only the earlier purchased-rate exclusion below; broader modes and combinations +remain unfinished. + +Evidence: [origin binding](../game/richese-cunning-rate.ts), +[origin cases](../tests/richese-cunning-rate.test.ts), +[native source](../tests/fixture-richese-cunning-purchased-rate.ts), +[runtime cases](../tests/richese-cunning-purchased-rate-runtime.test.ts), +[shared controls](../components/richese-no-field.tsx) and +[payment adapter](../game/homeworld-payment-options.ts). + +Frozen 20-file **168/168**, types/lint/build and four native selector-callback/ +engine/GameTable SSR journeys pass. Basic 126/Advanced 141 source actions earn +original setup/alliance/draw/funding and the actual Emperor activation; an +original fourth Guild receives no bank-paid fee, cancels the No-Field for a +real replacement, or uses Advanced stopping with the original exact refund. +Eleven paid plus immediate five cost six. An actual sixteen-paid/capped-four +invoice is nine, affordable with twelve where full seventeen is not. Zero/one +rounding, six pure origin cases, controlled pending-source/claimed-cost metadata +and all four minimal profiles pass. Controlled cases do not claim a native +card-recovery programme. Private source-bound proof/HTML: +`/home/quintine/.local/share/dune/checkpoints/20261025-richese-cunning-purchased-rate/`. +No saved-room/D1/server/reset/restart/deployment operation occurred. Full modes, +comprehensive assurance, browser/mobile/deployed and parent hydration remain open. + ## High Homeworld Cunning accompaniment — 25 October 2026 **Prototyped / Partial.** An unallied native Richese holder may explicitly add @@ -27,7 +86,7 @@ changes this invoice. Observe native High before the whole group, permit its arrival to cross Low, then apply current Low movement rules to later actions. The source's ordinary -unallied/no-purchased-rate and distinct available token/nonrepeat conditions +unallied and distinct available token/nonrepeat conditions remain. Smuggler retains its separate single-marker route. Zero paid forces need no High entitlement and preserve ordinary Cunning, including scarce stock. The arrival counts actual paid plus immediate faceup counters and one effective diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index b6ea6624..a838fc57 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1255,7 +1255,8 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP | Ix High paid-Cyborg bonus | Provisional composition: observe High before the accepted ordinary group; add up to two actual remaining Suboid Tanks per paid Cyborg outside normal allowance/free quota, without Recruits multiplication or future debt. Preserve the paid invoice and original cancellation/stop outcomes. Prior La La La suppresses new grants; a delivered bonus is one existing free-return income event, not a second award. The card establishes the grant; quota, shortage and event classification are application policy, not a located Ix FAQ. | [Current contract and source boundaries](HOMEWORLD_BENEFITS_RULES.md#provisional-ix-high-paid-revival-bonus--25-october-2026) | | Low Homeworld / Tleilaxu revival prevention | Provisionally prevent the qualifying return as a whole while preserving the printed Low free rate. No independent bonus-only return, cost, quota use, revival income or new High follow-up arises from a stopped event. Quiet and pending native special-Karama windows share the same eligibility. Retain the 24 October Ghola phase4 cutover and Emperor special immunity. This event/advantage composition is not a located combined FAQ. | [Current source and policy](HOMEWORLD_BENEFITS_RULES.md#provisional-low-homeworld-revival-prevention--25-october-2026) | | High Richese reserve accompaniment | The printed High card permits reserve forces beside a No-Field. Provisionally bill one marker plus the actual paid companions as one rounded ordinary group, observe High before that group and permit its completed shipment to cross Low. Hidden value stays in reserves; optional Smuggler reserves an additional real free counter. Original No-Field/Guild decisions and invoices remain, and the two-token Cunning route retains its separate contract. Combined tariff and grouped threshold timing are composition policy, not a printed worked example. | [Current prototype and boundaries](HOMEWORLD_RULES.md#high-richese-mixed-shipment-prototype--25-october-2026) | -| High Richese / Nexus Cunning paid group | Provisionally price the pair as one unit plus explicit paid ordinary counters; reserve the paid group first, then immediately materialize only the remaining stock for the faceup token. Observe High before the atomic group, permit crossing Low and retain the concealed token's later reserve-capped reveal. Original Nexus spend, two-token history, No-Field cancellation and Guild stop remain. No new Smuggler/purchased-rate pair or retrofitted quantity. This is combined pricing/allocation policy, not a located worked example. | [Current combination contract](NEXUS_RICHESE_RULES.md#high-homeworld-cunning-accompaniment--25-october-2026) | +| High Richese / Nexus Cunning paid group | Provisionally price the pair as one unit plus explicit paid ordinary counters; reserve the paid group first, then immediately materialize only the remaining stock for the faceup token. Observe High before the atomic group, permit crossing Low and retain the concealed token's later reserve-capped reveal. Original Nexus spend, two-token history, No-Field cancellation and Guild stop remain. No new Smuggler pair or retrofitted quantity; the later purchased-rate composition below is separate. This is combined pricing/allocation policy, not a located worked example. | [Current combination contract](NEXUS_RICHESE_RULES.md#high-homeworld-cunning-accompaniment--25-october-2026) | +| Purchased Karama / native Richese Cunning | Provisionally apply the original independent half-normal, bank-paid grant to one pair unit plus any paid High companions, rounded once. Bind original beneficiary/owner/optional activation card even at unchanged city fee. Keep live claimed-cost history, original No-Field unused-grant/spent-Nexus outcome and existing provisional Guild-stop exact-card refund. No native Guild rate or replacement grant is invented. | [Current source and boundaries](NEXUS_RICHESE_RULES.md#purchased-karama-rate-with-native-cunning--25-october-2026) | | Homeworld effects | Keep original payment/revival/placement identities and qualification observations; observations alone do not choose an occupied-benefit owner. | [Payments](HOMEWORLD_PAYMENT_INCOME_RULES.md), [card economy](HOMEWORLD_CARD_ECONOMY_RULES.md), [Collection](HOMEWORLD_COLLECTION_RULES.md), [revival deployment](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md), [victory reinforcement](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md), [replacement](HOMEWORLD_REPLACEMENT_RULES.md), [Tupile](HOMEWORLD_TUPILE_INTELLIGENCE_RULES.md) | | Duke Vidal revival custody | The user selected living set-aside custody after Ecaz revives Duke; a separate Ambassador/Nexus acquisition grants battle control. Only Ecaz may revive him, including Ghola. Native repeated returns now use the separately labelled provisional identity-cohort policy, not a new publisher example or user number. Captured scope and broad combinations remain separately bounded. | [Selected custody](ECAZ_REVIVAL_RULES.md#selected-product-interpretation-28-september-2026), [current cohort policy](ECAZ_REVIVAL_RULES.md#identity-tracked-repeated-cohorts--14-october-2026) | | Mirror Weapon copied-card cleanup | The user selected ordinary winner retention for the physical Mirror even if its effective copy activates Tooth or Artillery. The original Tooth/Artillery cards retain their named mandatory-discard exceptions. This is a product interpretation, **not** a located publisher ruling. Independent copy-first Tooth/Stone choices and physical cleanup have a bounded CHOAM/Richese-deck runtime, including bare Basic native E3 and selected Ix with original fixed/typed coalition accounting. Basic E3 phase modules and other combined modules/full games remain gated. | [Printed Mirror face and source audit](MIRROR_WEAPON_ENGINE_AUDIT.md#user-selected-cleanup-interpretation-28-september-2026) | diff --git a/game/bots.ts b/game/bots.ts index aa977949..f5ccbddc 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -3425,7 +3425,7 @@ function policyActions(g: GameView): Action[] { const group = quoteRicheseHomeworldPair({ enabled: !!noField.homeworldForcesEnabled, reserves: me.reserves, amount, revealedValue: revealed.value }); const target = markerTargets.slice(0, 24).find(to => - reserveShipmentCost({ faction: me.faction, halfRate: false }, + reserveShipmentCost({ faction: me.faction, halfRate: g.karamaShipping?.player === me.id }, territory(to.t).type, group.priced) <= (me.spice ?? 0)); if (target) return [{ type: 'ship', noField: concealed.id, revealedToken: revealed.id, diff --git a/game/engine.ts b/game/engine.ts index 8d894a6b..5da9bb4c 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -59,6 +59,7 @@ import { quoteNexusMoritaniRetention, nexusMoritaniRetentionSignature, type Nexu import { quoteNexusChoamBetrayal, validateNexusChoamBetrayal, nexusChoamBetrayalSignature, type NexusChoamBetrayal } from './nexus-choam-betrayal'; import { quoteEcazInquiry, ecazInquiryAnswer, ecazInquiryHistory, ecazInquirySignature, validateEcazInquiry, type EcazInquiryReceipt } from './nexus-ecaz-inquiry'; import { nexusCleanPlayBlocked } from './nexus-play-boundary'; +import { sourceForRicheseCunning, richeseCunningRateMatches, validRicheseCunningRateSource, RicheseCunningRateError, type RicheseCunningRateSource } from './richese-cunning-rate'; import { EMPEROR_NEXUS_REVIVALS, emperorNexusLegacyRevivalModeSupported, quoteEmperorNexusRevival, createEmperorNexusRevival, settleEmperorNexusRevival, validateEmperorNexusRevival, type NexusEmperorRevival } from './nexus-emperor-secret-ally'; import { emperorNexusPurchaseEvent, quoteEmperorNexusPurchase, signEmperorNexusPurchase, validateEmperorNexusPurchase, emperorNexusBankPurchaseModeSupported, emperorNexusSellerPurchaseModeSupported, quoteEmperorNexusSellerPurchase, createEmperorNexusSellerPurchase, emperorNexusAmbassadorPurchaseModeSupported, quoteEmperorNexusAmbassadorPurchase, createEmperorNexusAmbassadorPurchase, quoteEmperorNexusSpecialPurchase, createEmperorNexusSpecialPurchase, type EmperorNexusPurchase } from './nexus-emperor-purchase'; import { createEmperorPaymentReceipt, validateEmperorPaymentReceipt, emperorPaymentUseBlock, emperorPaymentSourceQuote, requiredEmperorPaymentResponders, emperorPaymentReceiptSignature, type EmperorPaymentReceipt } from './nexus-emperor-payment'; @@ -1217,7 +1218,7 @@ type PendingShipment = { guildRateEvent?: string; guildNexusEvent?: string; nexusEvent?: string; - richesePair?: { event: string; revealedTokenId: string }; + richesePair?: { event: string; revealedTokenId: string; karama?: RicheseCunningRateSource }; homeworldSources?: NativeReserveSelections; source?: 'ambassador'; ambassadorEvent?: string; @@ -31458,7 +31459,7 @@ function nexusRicheseCunningOffer(g: Game, owner: string) { else if (p.ally) blocked = 'Nexus Cunning requires an unallied Richese holder.'; else if (g.status !== 'playing' || g.phase !== 5 || g.active !== owner || !shipmentAvailable(g, p)) blocked = 'Use Cunning for your unused shipment before movement.'; - else if (nexusCleanPlayBlocked(g) || g.karamaShipping) + else if (nexusCleanPlayBlocked(g) || (g.karamaShipping && g.karamaShipping.player !== owner)) blocked = 'Finish the current interaction or Karama shipment before using Cunning.'; else blocked = noFieldShipBlock(g, p); return { @@ -31470,6 +31471,13 @@ function richesePairSignature(record: NonNullable quoteNativeReserveWithdrawal(homeworldContext(g), g.homeworlds!.custody!, p.id, { normal: group.physical, elite: 0 })); - const cost = reserveShipmentCost({ faction: p.faction, halfRate: false }, + const cost = reserveShipmentCost({ faction: p.faction, halfRate: !!pair.karama }, territory(shipment.territory).type, group.priced); requireRule(shipment.cost === cost && Number.isSafeInteger(shipment.sector) && validGameLocation(g, shipment.territory, shipment.sector), @@ -31564,6 +31574,11 @@ function richesePairIntegrity(g: Game) { frame.richesePair?.event === record.event && frame.player === record.owner && frame.noField?.event === record.tokenEvent && frame.turn === record.turn && typeof frame.noField.tokenId === 'string' && typeof frame.richesePair.revealedTokenId === 'string' && + Object.keys(frame.richesePair).sort().join(',') === (frame.richesePair.karama === undefined + ? 'event,revealedTokenId' : 'event,karama,revealedTokenId') && + (frame.richesePair.karama === undefined || (validRicheseCunningRateSource(frame.richesePair.karama) && + frame.richesePair.karama.player === frame.player && + g.players.some(player => player.id === frame.richesePair!.karama!.owner))) && g.players.some(player => player.id === record.owner && player.faction === 'richese' && player.noField?.tokens.some(token => token.id === frame.noField?.tokenId) && player.noField?.tokens.some(token => token.id === frame.richesePair?.revealedTokenId)), @@ -36745,7 +36760,9 @@ function applyActionInner( requireRule(Object.keys(action).filter(key => key !== 'noFieldReserves').sort().join(',') === 'allyPayment,event,nexus,noField,revealedToken,sector,territory,type', 'Choose exactly two physical No-Field tokens and a shipment destination.'); - richesePair = { event: offer.event, revealedTokenId: stringField(action.revealedToken) }; + const karama = richeseCunningRateSource(g, id); + richesePair = { event: offer.event, revealedTokenId: stringField(action.revealedToken), + ...(karama ? { karama } : {}) }; } else if (action.nexus !== undefined && action.nexus === nexusGuildSecretAllyOffer(g,id)?.event) { guildSecretEvent = validateGuildSecretDeclaration(g,id,action.nexus); requireRule(Object.keys(action).every(k => ['type','territory','sector','amount','elite','homeworldSources','allyPayment','nexus'].includes(k)), diff --git a/game/homeworld-payment-options.ts b/game/homeworld-payment-options.ts index c760db21..6542f6a3 100644 --- a/game/homeworld-payment-options.ts +++ b/game/homeworld-payment-options.ts @@ -26,7 +26,7 @@ export function botHomeworldShipmentPaymentAllowed(g: GameView, action: Action): const group = quoteRicheseHomeworldPair({ enabled: !!info.homeworldForcesEnabled, reserves: me.reserves, amount: paid, revealedValue: revealed.value }); - return reserveShipmentCost({ faction: me.faction, halfRate: false }, + return reserveShipmentCost({ faction: me.faction, halfRate: g.karamaShipping?.player === me.id }, territory(to).type, group.priced) <= (me.spice ?? 0); } catch { return false; } } diff --git a/game/reference.ts b/game/reference.ts index f79c1157..c21058fe 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -5262,6 +5262,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'When Ecaz is absent, an unallied holder may spend its Nexus card at a quiet play boundary to ask whether any one player, including themself, holds one of the holder’s native faction leaders as an ordinary Traitor Card. The owner receives a private yes/no answer saved at the moment of use; no name or count is disclosed. Dead or captured native leaders count, but foreign Gholas, Duke Vidal, Cheap Hero and Face Dancers do not. Native-leader scope and private-only audience are explicit product interpretations, not publisher rulings; exceptional controlled-leader and public-disclosure combinations remain open.', 'When Richese is absent, its Secret Ally replaces your ordinary reserve shipment with up to five physical forces charged as one. Forces, elite identity, destination restrictions, native Homeworld sources and movement remain unchanged. Fremen retain free reinforcement. Native Guild rate cancellation changes the one-counter sand bill from1 to2 without canceling the Nexus source; an already canceled rate is reflected in the quote. Unfunded declared Nexus stays spent without refund/reusable coupon while ordinary shipment remains available under the selected affordability policy. An independently purchased ordinary price remains separate. This is no concealed marker or extra shipment.', 'Native Richese Cunning uses the original signed two-No-Field shipment in supported single/paired E2 or selected mixed Ix+CHOAM native Nexus tables with classics, optional Skills/Homeworlds/Discovery/Tech/Advanced Strongholds. Select two distinct unused physical tokens: one reveals immediately into reserve-capped forces, one remains concealed; pay one marker invoice and spend the Nexus. The previous shipment token cannot be either choice, and the concealed one becomes last-used. Native/Guild response and actual payer/counter sources remain; a nested destination must be revealed. Prevention keeps physical tokens and unpaid spice, after any printed card cost. Sandtrout, arbitrary companions, shared allocation and other pending combinations retain their guards.', + 'An independently purchased Karama rate also applies to native unallied Richese Cunning: bill one pair unit plus any High paid companions, halve and round up once, and pay the Spice Bank. The original grant stays bound through responses. No-Field prevention leaves it available for a legal ordinary replacement while Nexus stays spent. Advanced Guild stopping uses the shipment and clears the grant, returning only its original unclaimed activation card still in discard under the existing provisional refund policy. A previously claimed cost cannot refund after rediscard.', 'Richese Betrayal has a separate opt-in auction preview. Before payment, an unallied rival may spend its physical Richese Nexus card to discard a public self-cache win without any purchase debit, or redirect another buyer’s cache/Black Market payment to the bank while preserving delivery and earned bonus. Every publicly possible holder acknowledges the same opportunity; private Use eligibility cannot determine public timing. Normal hidden self-purchases and private special-Karama acquisitions are not connected. See the dedicated guide for its existing local runtime/browser evidence and remaining release gates; that evidence does not verify Nexus Kull.', 'Guild Cunning is declared when finishing the ordinary Shipment and Movement turn. It spends the Nexus and opens its native Karama response. Allowance grants one second native shipment at normal Guild prices, including supported cross, return and Homeworld routes; cancellation preserves the first shipment and movement. Every later movement must spend an earned unused Hajr grant; there is no new ordinary move. A genuinely recovered and legally replayed Hajr adds another grant, with fresh post-movement timing in Advanced. Classic core retains original funding through Guild fee acknowledgement; other envelopes keep their source limits. The physical Ornithopter may provisionally replace one unused Hajr grant. Both original card groups remain legal until completed or abandoned, with extra credit only after both actually move. Earlier completed flight credits remain counted; declaring Cunning during an unfinished flight abandons its remaining group before the original Nexus response resumes. This adds no ordinary move.', 'Cunning native cross/return permission and rate use separate unpaid controls while original shipped history stays unchanged. A denial within its second opportunity allows a legal reserve replacement; denial of an earlier ordinary opportunity does not remove that new signed grant. Typed sources, original prices and approval stay bound; neither first payment nor ordinary movement reopens. Independent purchased price, immune Junction and other card sources remain distinct.', @@ -5274,10 +5275,10 @@ export const RULE_TOPICS: RuleTopic[] = [ ], related: ['ecaz-modules', 'spice-blow', 'alliance-funding', 'choam-worthless', 'choam-kull', 'nexus-choam-kull', 'nexus-richese-betrayal', 'nexus-guild-betrayal', 'nexus-ixian-replacement', 'nexus-ixian-betrayal'], checklist: [ - {area:"Implementation",status:'Partial',detail:"Native Fremen Cunning/Betrayal now retains selected modules, original occurrences, typed rides, optional Sandmaster and source-parent HMS entry. Great Maker prevention and completed-history timing use original continuation. Ecaz Betrayal now composes typed World returns, advisors, actual HMS and provisional face-down marker recall; Guild/Sapho timing preserves a full unspent opening. Native module Atomics retains actual paid arrival and original Cunning desert grants. Selected E1/E2/E3 independent/Nexus now shares fresh setup and original native consumers: typed Ix fees/Harass/substitution, CHOAM fuel, Richese paired invoices, native Occupy/Assassinate, shared arrival order and Duke Cunning. Existing trained-capture and unfinished individual-effect envelopes remain. Native cross/return permission and rate remain separate; Cunning preserves its opportunity. Independent typed purchases and explicit immune Junction source choice retain their original tariffs/payees. Richese Secret Ally now keeps one billed counter and all five physical forces through native Guild rate allowance/cancellation; signed pending/settled sources stay bound. Unfunded release retains spent Nexus without debt/refund/reusable coupon and preserves ordinary shipment. Marker approvals still cannot silently increase; full acceptance remains open.",evidence:['game/fremen-nexus-profile.ts','game/nexus-fremen-cunning.ts','game/nexus-fremen-betrayal.ts','game/sandmaster-worm.ts','tests/e3-independent-nexus-occupy.test.ts','tests/fixture-e3-independent-nexus-occupy.ts','tests/e3-independent-nexus-cards.test.ts','tests/fixture-e3-independent-nexus-cards.ts','tests/e3-independent-nexus-duke.test.ts','tests/fixture-e3-independent-nexus-duke.ts','tests/e3-independent-nexus-arrival.test.ts','tests/fixture-e3-independent-nexus-arrival.ts','game/faction-module-profile.ts','tests/selected-independent-nexus-entry.test.ts','tests/fixture-selected-independent-nexus-entry.ts','tests/selected-independent-nexus-battles.test.ts','tests/fixture-selected-independent-nexus-battles.ts','tests/selected-independent-nexus-cards.test.ts','tests/fixture-selected-independent-nexus-cards.ts','tests/selected-independent-ixian-betrayal.test.ts','game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts', 'game/nexus-fremen-protection.ts', 'game/admin-seat-ai-shape.ts', 'game/richese-cancellation.ts', 'game/bot-mobility.ts', 'game/planetologist-movement.ts', 'game/revival.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally.ts', 'game/revival-resume.ts', 'game/revival-cancellation.ts', 'game/homeworld-revival-deployment.ts', 'game/homeworld-revival-return.ts', "game/nexus-emperor-purchase.ts", "game/auction-continuation-quote.ts", "game/nexus-emperor-purchase.ts", "game/ambassador-phase.ts", "game/ambassador-resume.ts", "game/battle-aftermath-quote.ts", "game/ecaz-duke-acquisition.ts", "game/ecaz-duke-revival.ts", "game/nexus-ecaz-duke-options.ts", "game/nexus-play-boundary.ts", "game/junction-transport-options.ts"]}, - {area:"Player controls",status:'Partial',detail:"Fremen table SSR retains original Cunning spend/ride, Betrayal and Great Maker controls; an actual pending Sandmaster quote also renders its shared checkbox. Native Betrayal/Atomics table SSR retains typed/concealed return, revelation, ordered arrival, Aftermath and desert controls; CSS stubs mean no visual/browser acceptance. Earlier selected E3 lead/plan, assassination/revival and Duke controls retain their dated proof. Four earlier original-action390px journeys prove native Junction5/full2, poor1/full2 release plus ordinary one-force replacement, still-held source sand2 disabled then five-force stronghold1, and independent purchased1. The response explains the spent-card consequence. No overflow, browser error or application exception; two tool-only stale-node errors were resolved by fresh evidence without replay. Earlier Junction/loan/refund surfaces remain scoped evidence.",evidence:['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx', 'components/nexus-fremen-revival.tsx','components/nexus-emperor-payment.tsx', 'components/nexus-emperor-secret-ally.tsx', "components/nexus-emperor-seller-purchase.tsx", "components/nexus-emperor-ambassador-purchase.tsx", "components/richese-special-karama.tsx", "components/junction-transport.tsx"]}, - {area:"AI",status:'Partial',detail:"All four policies retain original Fremen Cunning and both Betrayal declarations with shared destination legality. All four policies retain minimal legal Ecaz Betrayal and original Aftermath/Sneak participation. All four policies execute original selected E3 Duke Cunning and unused expiry, and legal Occupy plans with real World sources. All four original policies also execute a legal still-held Richese full-rate one-counter alternative after native cancellation, with actual physical delivery and card cost. Shared human/AI quotes no longer promise a canceled discount. Native permission alternatives, recipient-specific lender prices and immune Junction source choice remain. Optional non-reoffer is not forced. No strategic first-candidate pin, tuning or calibration.",evidence:['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally-options.ts']}, - {area:"Documentation",status:'Partial',detail:"Selected E3 scope, printed two-seat no-alliance draw limit and remaining individual-effect envelopes are explicit in the canonical Nexus contract. Richese printed one-counter pricing and native Guild eligibility stay distinct from absent-faction cancellation, physical force count and independent purchased price. Unfunded spent-card/no-refund release is selected existing no-debt composition, not a new publisher worked example. Immune Junction and earlier permission/refund/loan interpretations remain explicit. Full rules and public gates stay open.",evidence:['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/GUILD_RATE_KARAMA_RULES.md', 'docs/RICHESE_ALLIED_SHIPMENT_AUDIT.md', 'docs/GUILD_HOMEWORLD_TRANSPORT_RULES.md', 'docs/NEXUS_GUILD_RULES.md', "docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md", "docs/RULE_DECISIONS.md", "docs/RICHESE_AMBASSADOR_RULES.md", "docs/RICHESE_AMBASSADOR_IMPLEMENTATION.md", "docs/RICHESE_ACQUISITION_RULES.md", "docs/DUKE_VIDAL_RULES.md", "docs/ECAZ_DUKE_ACQUISITION.md", "docs/ECAZ_REVIVAL_RULES.md", "docs/JUNCTION_TRANSPORT_RULES.md", "docs/JUNCTION_TRANSPORT_RUNTIME.md"]}, + {area:"Implementation",status:'Partial',detail:"Native Richese Cunning now binds the original purchased Karama source and its one rounded bank invoice, preserving live claimed-cost history and original cancellation/stop outcomes. Native Fremen Cunning/Betrayal now retains selected modules, original occurrences, typed rides, optional Sandmaster and source-parent HMS entry. Great Maker prevention and completed-history timing use original continuation. Ecaz Betrayal now composes typed World returns, advisors, actual HMS and provisional face-down marker recall; Guild/Sapho timing preserves a full unspent opening. Native module Atomics retains actual paid arrival and original Cunning desert grants. Selected E1/E2/E3 independent/Nexus now shares fresh setup and original native consumers: typed Ix fees/Harass/substitution, CHOAM fuel, Richese paired invoices, native Occupy/Assassinate, shared arrival order and Duke Cunning. Existing trained-capture and unfinished individual-effect envelopes remain. Native cross/return permission and rate remain separate; Cunning preserves its opportunity. Independent typed purchases and explicit immune Junction source choice retain their original tariffs/payees. Richese Secret Ally now keeps one billed counter and all five physical forces through native Guild rate allowance/cancellation; signed pending/settled sources stay bound. Unfunded release retains spent Nexus without debt/refund/reusable coupon and preserves ordinary shipment. Marker approvals still cannot silently increase; full acceptance remains open.",evidence:['game/fremen-nexus-profile.ts','game/nexus-fremen-cunning.ts','game/nexus-fremen-betrayal.ts','game/sandmaster-worm.ts','tests/e3-independent-nexus-occupy.test.ts','tests/fixture-e3-independent-nexus-occupy.ts','tests/e3-independent-nexus-cards.test.ts','tests/fixture-e3-independent-nexus-cards.ts','tests/e3-independent-nexus-duke.test.ts','tests/fixture-e3-independent-nexus-duke.ts','tests/e3-independent-nexus-arrival.test.ts','tests/fixture-e3-independent-nexus-arrival.ts','game/faction-module-profile.ts','tests/selected-independent-nexus-entry.test.ts','tests/fixture-selected-independent-nexus-entry.ts','tests/selected-independent-nexus-battles.test.ts','tests/fixture-selected-independent-nexus-battles.ts','tests/selected-independent-nexus-cards.test.ts','tests/fixture-selected-independent-nexus-cards.ts','tests/selected-independent-ixian-betrayal.test.ts','game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts', 'game/nexus-fremen-protection.ts', 'game/admin-seat-ai-shape.ts', 'game/richese-cancellation.ts', 'game/bot-mobility.ts', 'game/planetologist-movement.ts', 'game/revival.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally.ts', 'game/revival-resume.ts', 'game/revival-cancellation.ts', 'game/homeworld-revival-deployment.ts', 'game/homeworld-revival-return.ts', "game/nexus-emperor-purchase.ts", "game/auction-continuation-quote.ts", "game/nexus-emperor-purchase.ts", "game/ambassador-phase.ts", "game/ambassador-resume.ts", "game/battle-aftermath-quote.ts", "game/ecaz-duke-acquisition.ts", "game/ecaz-duke-revival.ts", "game/nexus-ecaz-duke-options.ts", "game/nexus-play-boundary.ts", "game/junction-transport-options.ts"]}, + {area:"Player controls",status:'Partial',detail:"Richese pair controls retain the purchased combined tariff and persistent bank/rate/cancellation explanation. Fremen table SSR retains original Cunning spend/ride, Betrayal and Great Maker controls; an actual pending Sandmaster quote also renders its shared checkbox. Native Betrayal/Atomics table SSR retains typed/concealed return, revelation, ordered arrival, Aftermath and desert controls; CSS stubs mean no visual/browser acceptance. Earlier selected E3 lead/plan, assassination/revival and Duke controls retain their dated proof. Four earlier original-action390px journeys prove native Junction5/full2, poor1/full2 release plus ordinary one-force replacement, still-held source sand2 disabled then five-force stronghold1, and independent purchased1. The response explains the spent-card consequence. No overflow, browser error or application exception; two tool-only stale-node errors were resolved by fresh evidence without replay. Earlier Junction/loan/refund surfaces remain scoped evidence.",evidence:['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx', 'components/nexus-fremen-revival.tsx','components/nexus-emperor-payment.tsx', 'components/nexus-emperor-secret-ally.tsx', "components/nexus-emperor-seller-purchase.tsx", "components/nexus-emperor-ambassador-purchase.tsx", "components/richese-special-karama.tsx", "components/junction-transport.tsx"]}, + {area:"AI",status:'Partial',detail:"The Richese minimal pair candidate and shared payment guard use the original beneficiary purchased rate; strategy remains deferred. All four policies retain original Fremen Cunning and both Betrayal declarations with shared destination legality. All four policies retain minimal legal Ecaz Betrayal and original Aftermath/Sneak participation. All four policies execute original selected E3 Duke Cunning and unused expiry, and legal Occupy plans with real World sources. All four original policies also execute a legal still-held Richese full-rate one-counter alternative after native cancellation, with actual physical delivery and card cost. Shared human/AI quotes no longer promise a canceled discount. Native permission alternatives, recipient-specific lender prices and immune Junction source choice remain. Optional non-reoffer is not forced. No strategic first-candidate pin, tuning or calibration.",evidence:['game/richese-cunning-rate.ts', 'game/homeworld-payment-options.ts', 'game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally-options.ts']}, + {area:"Documentation",status:'Partial',detail:"The native Richese pair/purchased rate composition and retained provisional exact-card refund are explicit in its canonical source contract. Selected E3 scope, printed two-seat no-alliance draw limit and remaining individual-effect envelopes are explicit in the canonical Nexus contract. Richese printed one-counter pricing and native Guild eligibility stay distinct from absent-faction cancellation, physical force count and independent purchased price. Unfunded spent-card/no-refund release is selected existing no-debt composition, not a new publisher worked example. Immune Junction and earlier permission/refund/loan interpretations remain explicit. Full rules and public gates stay open.",evidence:['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/GUILD_RATE_KARAMA_RULES.md', 'docs/RICHESE_ALLIED_SHIPMENT_AUDIT.md', 'docs/GUILD_HOMEWORLD_TRANSPORT_RULES.md', 'docs/NEXUS_GUILD_RULES.md', "docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md", "docs/RULE_DECISIONS.md", "docs/RICHESE_AMBASSADOR_RULES.md", "docs/RICHESE_AMBASSADOR_IMPLEMENTATION.md", "docs/RICHESE_ACQUISITION_RULES.md", "docs/DUKE_VIDAL_RULES.md", "docs/ECAZ_DUKE_ACQUISITION.md", "docs/ECAZ_REVIVAL_RULES.md", "docs/JUNCTION_TRANSPORT_RULES.md", "docs/JUNCTION_TRANSPORT_RUNTIME.md"]}, {area:"Verification",status:'Partial',detail:"Independent Discovery adds frozen15-file118/118, types/lint/build and six original-action/rendered-control programmes; broader modes, visual/deployed acceptance and final assurance remain open. Native Fremen modules pass frozen14-file86/86, types/lint/build and nine original-action/rendered-control programmes. HMS has genuine runtime proof; Discovery destination arrival remains quote-only evidence. Earlier native Betrayal/Atomics composition passes frozen16-file115/115, types/lint/build and eight finite original-action/table-SSR programmes, including original Cunning desert Aftermath; visual/browser and deployed acceptance remain open. Earlier selected E3 entry, Occupy, Assassinate/Recruits, Duke and arrival proof remains dated in the canonical contract. Earlier selected E1/E2 native card/cancellation proof remains dated in the canonical Nexus contract. Classic core retains original World/Tech/Stronghold modules with base33/selected36 and optional14. Independent World cases use native setup, population-sensitive Recruits, typed foreign Harass and actual own-Kaitain Reinforcements/Cunning. The local fee reduces only its paid temporary roles; original count5 and real stars remain. Earlier phase/card proofs are dated in the canonical contract. Native-family/Discovery/Semuta envelopes, full-mode and comprehensive assurance remain open.",evidence:['tests/independent-discovery-nexus-entry.test.ts', 'tests/independent-discovery-cards.test.ts', 'tests/independent-discovery-battles.test.ts', 'tests/fremen-cunning-modules.test.ts','tests/fixture-fremen-cunning-modules.ts','tests/fremen-betrayal-modules.test.ts','tests/fixture-fremen-betrayal-modules.ts','tests/nexus-fremen-cunning-modules-quotes.test.ts','tests/ecaz-betrayal-modules.test.ts','tests/fixture-ecaz-betrayal-modules.ts','tests/richese-no-field-hidden-return.test.ts','tests/nexus-ecaz-betrayal-quotes.test.ts','tests/moritani-atomics-modules.test.ts','tests/nexus-moritani-atomics-runtime.test.ts','tests/nexus-world-independent-cards.test.ts','tests/nexus-world-independent-battles.test.ts','tests/reinforcements-temporary-role.test.ts','tests/nexus-core-tech-runtime.test.ts','tests/fixture-nexus-core-tech.ts','tests/nexus-core-strongholds-runtime.test.ts','tests/fixture-nexus-core-strongholds.ts','tests/classic-nexus-core.test.ts','tests/fixture-classic-nexus-core.ts','game/faction-module-profile.ts','tests/classic-independent-nexus-utility.test.ts', 'tests/classic-independent-nexus-battle.test.ts', 'tests/fixture-classic-independent-nexus.ts', 'tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/native-guild-rate-karama.test.ts', 'tests/native-guild-rate-modules.test.ts', 'tests/allied-marker-guild-rate.test.ts', 'tests/purchased-homeworld-rate.test.ts', 'tests/native-guild-transport-permission.test.ts', 'tests/native-guild-homeworld-rate.test.ts', 'tests/native-guild-rate-betrayal.test.ts', 'tests/nexus-guild-cunning-engine.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts', 'tests/nexus-fremen-protection.test.ts', 'tests/fixture-nexus-fremen-protection.ts', 'tests/nexus-fremen-protection-runtime.test.ts', 'tests/fixture-stronghold-factions.ts', 'tests/fixture-nexus-advisor-fresh.ts', 'tests/nexus-advisor-fresh-runtime.test.ts', 'tests/fixture-nexus-advisor-storm.ts', 'tests/nexus-advisor-storm-runtime.test.ts', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'tests/nexus-fremen-free-quota.test.ts', 'tests/fixture-nexus-fremen-free-quota.ts', 'tests/nexus-fremen-free-quota-runtime.test.ts', 'tests/nexus-fremen-revival.test.ts','tests/nexus-emperor-payment.test.ts','tests/fixture-nexus-emperor-payment.ts','tests/nexus-emperor-payment-runtime.test.ts','docs/NEXUS_EMPEROR_RULES.md', 'tests/nexus-emperor-extra-source.test.ts', 'tests/nexus-emperor-extra-continuation.test.ts', 'tests/fixture-nexus-emperor-extra.ts', 'tests/nexus-emperor-extra-runtime.test.ts', 'tests/fixture-single-nexus-e1.ts', 'docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md', "tests/fixture-nexus-emperor-seller.ts", "tests/nexus-emperor-seller-purchase.test.ts", "tests/nexus-emperor-seller-runtime.test.ts", "tests/fixture-nexus-emperor-ambassador.ts", "tests/nexus-emperor-ambassador-purchase.test.ts", "tests/nexus-emperor-ambassador-runtime.test.ts", "tests/fixture-nexus-emperor-special.ts", "tests/nexus-emperor-special-purchase.test.ts", "tests/nexus-emperor-special-runtime.test.ts", "tests/karama-intents.test.ts", "tests/fixture-nexus-emperor-bank.ts", "tests/nexus-emperor-bank-profile.test.ts", "tests/nexus-emperor-bank-runtime.test.ts", "tests/duke-captured-custody.test.ts", "tests/nexus-ecaz-duke-captured.test.ts", "tests/fixture-nexus-duke-capture.ts", "tests/nexus-duke-capture-runtime.test.ts", "tests/nexus-duke-timing-runtime.test.ts", "tests/junction-purchased-price.test.ts", "tests/richese-native-guild-rate.test.ts"]}, ], }, diff --git a/game/richese-cunning-rate.ts b/game/richese-cunning-rate.ts new file mode 100644 index 00000000..e890532e --- /dev/null +++ b/game/richese-cunning-rate.ts @@ -0,0 +1,67 @@ +export type RicheseCunningRateSource = { + player: string; + owner: string; + card?: string; +}; +export type RicheseCunningRateGrant = RicheseCunningRateSource & { costClaimed?: true }; + +export class RicheseCunningRateError extends Error { + constructor(message: string) { + super(message); + this.name = 'RicheseCunningRateError'; + } +} + +const record = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); +const identifier = (value: unknown): value is string => + typeof value === 'string' && value.trim().length > 0; + +/** Identity snapshot only: live refund flags and physical card custody are not + * part of the original purchased-rate source. */ +export function validRicheseCunningRateSource(value: unknown): value is RicheseCunningRateSource { + return record(value) && Object.hasOwn(value, 'player') && Object.hasOwn(value, 'owner') && + Object.keys(value).every(key => key === 'player' || key === 'owner' || key === 'card') && + identifier(value.player) && identifier(value.owner) && + (!Object.hasOwn(value, 'card') || identifier(value.card)); +} + +/** Copy the active beneficiary's exact origin, leaving its mutable cost-claim + * history on the original grant. Legacy grants may omit the physical card. */ +export function sourceForRicheseCunning( + active: RicheseCunningRateGrant | null | undefined, + beneficiary: string, +): RicheseCunningRateSource | undefined { + if (!identifier(beneficiary)) + throw new RicheseCunningRateError('Choose the original purchased-rate beneficiary.'); + if (active === null || active === undefined) return undefined; + if (!record(active)) + throw new RicheseCunningRateError('The active purchased rate has an invalid origin.'); + const source = { + player: active.player, + owner: active.owner, + ...(active.card === undefined ? {} : { card: active.card }), + }; + if (!validRicheseCunningRateSource(source)) + throw new RicheseCunningRateError('The active purchased rate needs its original player, owner and optional card.'); + return source.player === beneficiary ? source : undefined; +} + +/** An unchanged rounded fee is insufficient: retain the original full source. + * Absence records that this beneficiary had no purchased rate at declaration. */ +export function richeseCunningRateMatches( + savedSource: RicheseCunningRateSource | undefined, + active: RicheseCunningRateGrant | null | undefined, + beneficiary: string, +): boolean { + if (savedSource !== undefined && !validRicheseCunningRateSource(savedSource)) return false; + try { + const current = sourceForRicheseCunning(active, beneficiary); + if (savedSource === undefined) return current === undefined; + return current !== undefined && savedSource.player === current.player && + savedSource.owner === current.owner && savedSource.card === current.card; + } catch (error) { + if (error instanceof RicheseCunningRateError) return false; + throw error; + } +} diff --git a/tests/fixture-richese-cunning-purchased-rate.ts b/tests/fixture-richese-cunning-purchased-rate.ts new file mode 100644 index 00000000..048a7cfa --- /dev/null +++ b/tests/fixture-richese-cunning-purchased-rate.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import { viewGame, type Action } from '../game/engine'; +import { reserveShipmentCost } from '../game/shipment-price'; +import { territory } from '../game/board'; +import { quoteRicheseHomeworldPair } from '../game/richese-homeworld-shipment'; +import { + createRicheseHomeworldCunningFixture, + actRicheseHomeworldCunning, nextRicheseHomeworldCunning, advanceRicheseHomeworldCunning, + passRicheseHomeworldCunning, finishRicheseHomeworldCunning, + commitRicheseHomeworldCunning, revealRicheseHomeworldCunning, + richeseHomeworldCunningPlayer, richeseHomeworldCunningClean, + richeseHomeworldCunningInventory, richeseHomeworldCunningNexusInventory, + richeseHomeworldCunningPopulation, +} from './fixture-richese-homeworld-cunning'; +import type { RicheseHomeworldShipmentStep } from './fixture-richese-homeworld-shipment'; + +export const actRicheseCunningPurchasedRate = actRicheseHomeworldCunning; +export const nextRicheseCunningPurchasedRate = nextRicheseHomeworldCunning; +export const advanceRicheseCunningPurchasedRate = advanceRicheseHomeworldCunning; +export const passRicheseCunningPurchasedRate = passRicheseHomeworldCunning; +export const finishRicheseCunningPurchasedRate = finishRicheseHomeworldCunning; +export const commitRicheseCunningPurchasedRate = commitRicheseHomeworldCunning; +export const revealRicheseCunningPurchasedRate = revealRicheseHomeworldCunning; +export const richeseCunningPurchasedRatePlayer = richeseHomeworldCunningPlayer; +export const richeseCunningPurchasedRateClean = richeseHomeworldCunningClean; +export const richeseCunningPurchasedRateInventory = richeseHomeworldCunningInventory; +export const richeseCunningPurchasedRateNexusInventory = richeseHomeworldCunningNexusInventory; +export const richeseCunningPurchasedRatePopulation = richeseHomeworldCunningPopulation; + +/** Native original sources plus Emperor's actual printed Karama purchase for + * active Richese. No source fields are added to the client pair declaration. + * The optional costClaimed flag is an explicitly controlled supplement only. */ +export function createRicheseCunningPurchasedRateFixture(options: { + advanced?: boolean; companions?: number; guild?: boolean; costClaimed?: boolean; +} = {}) { + const companions = options.companions ?? 11; + assert.ok(Number.isSafeInteger(companions) && companions >= 0 && companions <= 16); + const f = createRicheseHomeworldCunningFixture({ advanced: options.advanced, + companions: Math.max(1, companions), guild: options.guild }); + const actions: RicheseHomeworldShipmentStep[] = structuredClone(f.actions); + const beforePurchase = structuredClone(f.game), activationAction: Action = { + type: 'card', mode: 'shipment', card: f.karama, target: f.owner, + }; + let game = actRicheseCunningPurchasedRate(f.game, f.holder, activationAction, actions); + game = finishRicheseCunningPurchasedRate(game, actions); + const activation = { before: beforePurchase, steps: structuredClone(actions.slice(f.actions.length)), after: structuredClone(game) }; + assert.deepEqual(game.karamaShipping, { player: f.owner, owner: f.holder, card: f.karama }); + assert.equal(richeseCunningPurchasedRatePlayer(game, f.holder).hand.some(card => card.id === f.karama), false); + assert.equal(game.discard.filter(card => card.id === f.karama).length, 1); + if (options.costClaimed) Object.assign(game.karamaShipping!, { costClaimed: true as const }); + const p = richeseCunningPurchasedRatePlayer(game, f.owner), offer = viewGame(game, f.owner).nexusRicheseCunning; + assert.ok(offer && !offer.blocked, offer?.blocked ?? 'The original purchased rate leaves native Cunning available'); + const quote = quoteRicheseHomeworldPair({ enabled: true, reserves: p.reserves, amount: companions, revealedValue: f.revealedValue }); + const action: Action = { type: 'ship', noField: f.concealedToken, event: p.noFieldEvent, + revealedToken: f.revealedToken, nexus: offer.event, + ...(companions ? { noFieldReserves: companions } : {}), territory: f.territory, sector: f.sector, allyPayment: 0 }; + const cost = reserveShipmentCost({ faction: 'richese', halfRate: true }, territory(f.territory).type, quote.priced); + const guildCancel: Action | null = f.guildKarama ? { type: 'card', card: f.guildKarama, mode: 'cancel' } : null; + const guildStop: Action | null = f.guildKarama ? { type: 'card', card: f.guildKarama, mode: 'special' } : null; + assert.deepEqual(richeseCunningPurchasedRateInventory(game), f.originalInventory); + assert.deepEqual(richeseCunningPurchasedRateNexusInventory(game), f.originalNexusInventory); + return { ...f, game, source: structuredClone(game), beforeShipment: structuredClone(game), beforePurchase, + beforeActivation: beforePurchase, activation, activationAction, + purchaser: f.holder, shipper: f.owner, purchasedRate: structuredClone(game.karamaShipping!), + grant: structuredClone(game.karamaShipping!), controlledCostClaimed: !!options.costClaimed, + companions, noFieldReserves: companions, amount: companions, + action, shipmentAction: action, shipmentStep: { actor: f.owner, action }, + pairQuote: quote, paid: quote.paid, physical: quote.physical, priced: quote.priced, revealedForces: quote.revealed, + fullCost: reserveShipmentCost({ faction: 'richese', halfRate: false }, territory(f.territory).type, quote.priced), + cost, shipmentCost: cost, nexusEvent: offer.event, offer: structuredClone(offer), guildCancel, guildStop, + actions, steps: actions, sourceActionCount: actions.length, + staging: [...f.staging, + 'After the actual original alliance, closing Richese draw and funded unused turn, Emperor plays its genuinely dealt Karama in shipment mode for active Richese. That exact physical card enters discard once and the original owner-labelled purchased grant is retained. The pair action carries only original client fields; the server owns its price-source record.', + 'Optional Guild really receives the other starting Karama and passes its empty Nexus choice. Its original No-Field counter, Advanced shipment interception, bank routing and unclaimed-activation refund remain caller controls. No faction, card, force, alliance, phase or grant is installed.', + ...(options.costClaimed ? ['CONTROLLED SUPPLEMENT: after that genuine activation, set only its existing costClaimed flag true to represent previously claimed disposal cost. No physical card, duplicate identity, owner, phase, receipt or payment is created. This is not a claimed original Nullentropy programme.'] : []), + ], + }; +} +export type RicheseCunningPurchasedRateFixture = ReturnType; diff --git a/tests/fixture-richese-homeworld-cunning.ts b/tests/fixture-richese-homeworld-cunning.ts index 583ac0a6..0d5fcc12 100644 --- a/tests/fixture-richese-homeworld-cunning.ts +++ b/tests/fixture-richese-homeworld-cunning.ts @@ -29,12 +29,12 @@ export const richeseHomeworldCunningPopulation = richeseHomeworldShipmentPopulat * declaration. Emperor and Atreides actually ally at the turn-two worm; the * unallied Richese really draws its original Cunning singleton afterward. */ export function createRicheseHomeworldCunningFixture(options: { - advanced?: boolean; companions?: number; revealedValue?: NoFieldValue; concealedValue?: NoFieldValue; + advanced?: boolean; companions?: number; revealedValue?: NoFieldValue; concealedValue?: NoFieldValue; guild?: boolean; } = {}) { const revealedValue = options.revealedValue ?? 5, concealedValue = options.concealedValue ?? 3; assert.notEqual(revealedValue, concealedValue, 'The two original physical tokens must be distinct'); const f = createRicheseHomeworldShipmentFixture({ advanced: options.advanced, - companions: options.companions, value: concealedValue, nexus: true }); + companions: options.companions, value: concealedValue, nexus: true, guild: options.guild }); assert.ok(f.nexusDraw && f.nexusAlliance && f.observer); const p = richeseHomeworldCunningPlayer(f.game, f.owner), offer = viewGame(f.game, f.owner).nexusRicheseCunning; assert.ok(offer && !offer.blocked, offer?.blocked ?? 'The actually drawn native Richese Cunning is live'); @@ -53,7 +53,7 @@ export function createRicheseHomeworldCunningFixture(options: { value: concealedValue, markerValue: concealedValue, token: concealed.id, tokenId: concealed.id, paid: quote.paid, physical: quote.physical, priced: quote.priced, revealedForces: quote.revealed, pairQuote: quote, staging: [...f.staging, - 'This native three-seat branch stops before the original two-token Cunning declaration. Its Emperor/Atreides alliance is earned, Richese is unallied, the original Richese Nexus is actually held and empty Carthag11 is the funded destination. No two-seat all-unallied draw is manufactured.', + `This native ${f.guild ? 'four' : 'three'}-seat branch stops before the original two-token Cunning declaration. Its Emperor/Atreides alliance is earned, Richese is unallied, the original Richese Nexus is actually held and empty Carthag11 is the funded destination. No two-seat all-unallied draw is manufactured.`, 'The proposed pair uses one concealed three token and one immediate faceup five token by default, plus eleven paid original reserves. Root’s explicitly provisional composition reserves the paid group first, materializes only the remaining stock for the immediate token and leaves the other token concealed. The original Nexus spend, No-Field Karama counter, one combined invoice and later reserve-capped reveal remain real caller actions.', ], }; diff --git a/tests/fixture-richese-homeworld-shipment.ts b/tests/fixture-richese-homeworld-shipment.ts index 60ebcb5f..736463a5 100644 --- a/tests/fixture-richese-homeworld-shipment.ts +++ b/tests/fixture-richese-homeworld-shipment.ts @@ -50,6 +50,8 @@ export const richeseHomeworldShipmentNexusInventory = (game: Game) => { * really takes ordinary Charity when eligible; no balance is installed. */ export function nextRicheseHomeworldShipment(game: Game): RicheseHomeworldShipmentStep | null { if (game.truthtrance) return null; + if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard && game.decision?.kind === 'guildTiming') + return { actor: game.decision.player, action: { type: 'decision', take: false } }; if (game.nexusCards?.phase?.stage === 'drawing') { const phase = game.nexusCards.phase, actor = phase.eligible.find(id => !phase.done.includes(id)); assert.ok(actor); return { actor, action: { type: 'nexusCardChoice', turn: phase.turn, @@ -96,10 +98,12 @@ export function finishRicheseHomeworldShipment(game: Game, actions: RicheseHomew * cards and undeployed original No-Fields. Native deferred bribes fund one * combined invoice without changing either physical army. Stop BEFORE ship. */ export function createRicheseHomeworldShipmentFixture(options: { - advanced?: boolean; companions?: number; value?: NoFieldValue; nexus?: boolean; + advanced?: boolean; companions?: number; value?: NoFieldValue; nexus?: boolean; guild?: boolean; } = {}) { const advanced = options.advanced ?? false, companions = options.companions ?? 11, value = options.value ?? 5; const nexus = options.nexus ?? false; + const guild = options.guild ? 'guild' : null; + assert.ok(!guild || nexus, 'The bounded optional Guild source retains its actual Nexus programme'); assert.ok(Number.isSafeInteger(companions) && companions >= 1 && companions <= 16, 'The bounded native funding programme supports one through sixteen companions'); const owner = 'richese', holder = 'emperor', observer = nexus ? 'atreides' : null; @@ -108,6 +112,7 @@ export function createRicheseHomeworldShipmentFixture(options: { let game = createGame('RICHESEHOMEWORLDSHIPMENT', newPlayer(owner, 'Richese', 'richese'), advanced, ['choam']); joinGame(game, newPlayer(holder, 'Emperor', 'emperor')); if (observer) joinGame(game, newPlayer(observer, 'Atreides', 'atreides')); + if (guild) joinGame(game, newPlayer(guild, 'Guild', 'guild')); const initial = structuredClone(game), actions: RicheseHomeworldShipmentStep[] = []; const act = (actor: string, action: Action) => { game = actRicheseHomeworldShipment(game, actor, action, actions); }; const reach = (until: (state: Game) => boolean) => { game = advanceRicheseHomeworldShipment(game, until, actions); }; @@ -133,7 +138,9 @@ export function createRicheseHomeworldShipmentFixture(options: { const karama = game.deck.find(card => card.effect === 'karama'); assert.ok(ordinary && karama); const observerCard = observer ? game.deck.find(card => card.kind === 'shield') : null; assert.ok(!observer || observerCard); - const deal = [ordinary.id, karama.id, ...(observerCard ? [observerCard.id] : [])]; + const guildCard = guild ? game.deck.find(card => card.effect === 'karama' && card.id !== karama.id) : null; + assert.ok(!guild || guildCard); + const deal = [ordinary.id, karama.id, ...(observerCard ? [observerCard.id] : []), ...(guildCard ? [guildCard.id] : [])]; const dealOrder = deal.map((id, position) => { const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position); [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; @@ -149,6 +156,8 @@ export function createRicheseHomeworldShipmentFixture(options: { assert.deepEqual(richeseHomeworldShipmentPlayer(game, holder).hand.map(card => card.id), [karama.id]); if (observer && observerCard) assert.deepEqual(richeseHomeworldShipmentPlayer(game, observer).hand.map(card => card.id), [observerCard.id]); + if (guild && guildCard) + assert.deepEqual(richeseHomeworldShipmentPlayer(game, guild).hand.map(card => card.id), [guildCard.id]); assert.equal(richeseHomeworldShipmentPlayer(game, owner).reserves, 20); assert.deepEqual(richeseHomeworldShipmentPlayer(game, owner).forces, {}); const originalInventory = richeseHomeworldShipmentInventory(game); assert.equal(originalInventory.length, 45); @@ -165,6 +174,7 @@ export function createRicheseHomeworldShipmentFixture(options: { const beforeFunding = structuredClone(game), funding: RicheseHomeworldShipmentTransition[] = []; let beforeWorm: Game | null = null, beforeNexusDraw: Game | null = null; let nexusAlliance: RicheseHomeworldShipmentTransition | null = null, nexusDraw: RicheseHomeworldShipmentTransition | null = null; + let guildNexusPass: RicheseHomeworldShipmentTransition | null = null; let wormOrder: { position: number; from: number; card: Game['spiceDeck'][number] } | null = null; let nexusOrder: { position: number; from: number; card: 'richese' } | null = null; const wormFollowupOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = []; @@ -200,6 +210,10 @@ export function createRicheseHomeworldShipmentFixture(options: { nexusDraw = transition(() => { act(owner, { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 }); }); assert.equal(game.nexusCards!.cards!.hands[owner], 'richese'); + if (guild) guildNexusPass = transition(() => { + assert.equal(game.nexusCards!.cards!.hands[guild], null); + act(guild, { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'keep', ownRedraws: 0 }); + }); } reach(state => state.turn === turn && state.phase === 5 && state.active === owner && richeseHomeworldShipmentClean(state)); }; @@ -236,8 +250,10 @@ export function createRicheseHomeworldShipmentFixture(options: { nexusAlliance: nexusAlliance as RicheseHomeworldShipmentTransition | null, beforeNexusDraw: beforeNexusDraw as Game | null, nexusDraw: nexusDraw as RicheseHomeworldShipmentTransition | null, + guildNexusPass: guildNexusPass as RicheseHomeworldShipmentTransition | null, nexusOrder: nexusOrder as { position: number; from: number; card: 'richese' } | null, advanced, nexus, owner, richese: owner, actor: owner, holder, emperor: holder, observer, atreides: observer, + guild, guildKarama: guildCard?.id ?? null, companions, noFieldReserves: companions, amount: companions, physical: companions, priced: companions + 1, cost, shipmentCost: cost, value, markerValue: value, token: token.id, tokenId: token.id, event, shipmentEvent: event, territory, sector, location, action, shipmentAction: action, @@ -249,12 +265,15 @@ export function createRicheseHomeworldShipmentFixture(options: { actions, steps: actions, sourceActionCount: actions.length, staging: [ nexus - ? 'Actual public Basic/Advanced Richese/Emperor/Atreides lobby enables Homeworlds and Nexus Cards, then native ready/start with original main35/cache10, all12 Nexus singletons and all three native No-Field components. Only original scalar lotteries, three unopened Treachery positions and six unopened ordinary Spice positions are selected.' + ? guild + ? 'Actual public Basic/Advanced Richese/Emperor/Atreides/Guild lobby enables Homeworlds and Nexus Cards, then native ready/start with original main35/cache10, all12 Nexus singletons and all three native No-Field components. Only original scalar lotteries, four unopened Treachery positions and six unopened ordinary Spice positions are selected.' + : 'Actual public Basic/Advanced Richese/Emperor/Atreides lobby enables Homeworlds and Nexus Cards, then native ready/start with original main35/cache10, all12 Nexus singletons and all three native No-Field components. Only original scalar lotteries, three unopened Treachery positions and six unopened ordinary Spice positions are selected.' : 'Actual public Basic/Advanced Richese/Emperor lobby enables only Homeworlds, then native ready/start with original main35/cache10 and all three native No-Field components. Only original scalar lotteries, two unopened Treachery positions and six unopened ordinary Spice positions are selected.', 'Richese really receives an ordinary projectile and Emperor its printed original Karama. Every original Storm, Spice, Charity, native cache/normal auction, Black Market decline in Advanced, Revival, Movement ending and Mentat control is used. Real cache lots are passed and retired, never replaced or acquired by assigning a hand.', 'The default eleven-companion stronghold invoice needs twelve spice. Emperor really pays Richese a seven-spice bribe during turn one; its pending recipient share becomes available only at the actual Mentat. Richese’s next unused turn retains all twenty original reserves and HIGH population before declaration.', 'The sixteen-companion cap variant uses an actual ten-spice first bribe, next-turn Emperor Charity for two and a second two-spice bribe, both completed through real Mentat. Its third unused turn has seventeen spendable spice and the same untouched original twenty reserves. Smaller companion requests use only the native funding they need.', ...(nexus ? ['The original turn-two unopened Spice order supplies a real Shai-Hulud and ordinary land follow-ups. Emperor actually proposes and Atreides accepts alliance; Richese stays unallied. Only the unopened Richese Nexus singleton is moved to the next original draw position, then Richese really draws it at the closing Nexus choice. Two seats cannot earn that unallied draw; the native third seat supplies its real eligibility. Empty Carthag11 avoids Atreides’ original Arrakeen army.'] : []), + ...(guild ? ['The optional fourth Guild seat really receives the second original printed Karama in its unopened starting deal. Atreides keeps its original Shield; neither card is moved between hands. Guild explicitly keeps its empty position at the real Nexus closing choice and declines native early timing before the source. Its actual native five Tuek counters and starting resources remain earned state.'] : []), 'Stop BEFORE the marker/physical shipment. The original token/event, opponent-held Karama, quiet unused shipment and exact funded combined invoice remain live caller inputs. No force, wallet, phase, hand, stance, token custody, canceled history or pending proof is installed.', ], }; diff --git a/tests/richese-cunning-purchased-rate-runtime.test.ts b/tests/richese-cunning-purchased-rate-runtime.test.ts new file mode 100644 index 00000000..be9a0883 --- /dev/null +++ b/tests/richese-cunning-purchased-rate-runtime.test.ts @@ -0,0 +1,220 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { territory } from '../game/board'; +import { reserveShipmentCost } from '../game/shipment-price'; +import { quoteRicheseHomeworldPair } from '../game/richese-homeworld-shipment'; +import { botHomeworldShipmentPaymentAllowed } from '../game/homeworld-payment-options'; +import type { RicheseCunningRateSource } from '../game/richese-cunning-rate'; +import { + createRicheseCunningPurchasedRateFixture as fixture, + actRicheseCunningPurchasedRate as act, + passRicheseCunningPurchasedRate as pass, + finishRicheseCunningPurchasedRate as finish, + commitRicheseCunningPurchasedRate as commit, + richeseCunningPurchasedRatePlayer as player, + richeseCunningPurchasedRateInventory as inventory, + richeseCunningPurchasedRateNexusInventory as nexusInventory, +} from './fixture-richese-cunning-purchased-rate'; + +function frame(game: Game) { + const record = game.nexusRicheseCunningHistory?.at(-1); assert.ok(record); + return JSON.parse(record.frame) as { cost: number; richesePair: { karama?: RicheseCunningRateSource } }; +} +function reject(game: Game, actor: string, action: Action, reason?: RegExp) { + const before = structuredClone(game); + if (reason) assert.throws(() => applyAction(game, actor, action), reason); + else assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before); +} + +for (const advanced of [false, true]) { + void test(`${advanced ? 'Advanced' : 'Basic'} native three-seat Cunning uses Emperor's actual purchased rate for the combined twelve-unit invoice`, () => { + const f = fixture({ advanced }), before = structuredClone(player(f.game, f.owner)); + assert.equal(f.guild, null); assert.ok(f.observer); + assert.equal(player(f.beforePurchase, f.holder).hand.some(card => card.id === f.karama), true); + assert.deepEqual(f.activation.steps[0], { actor: f.holder, action: f.activationAction }); + assert.equal(f.activationAction.mode, 'shipment'); assert.equal(f.activationAction.target, f.owner); + assert.deepEqual(f.purchasedRate, { player: f.owner, owner: f.holder, card: f.karama }); + assert.equal(viewGame(f.game, f.owner).nexusRicheseCunning?.blocked, null); + assert.equal(f.fullCost, 12); assert.equal(f.cost, 6); + const result = commit(f.game, f), after = player(result.game, f.owner), saved = frame(result.game); + assert.equal(saved.cost, 6); assert.deepEqual(saved.richesePair.karama, f.purchasedRate); + assert.equal(Object.hasOwn(saved.richesePair.karama!, 'costClaimed'), false); + assert.equal(before.spice - after.spice, 6); + assert.equal(after.reserves, 4); assert.deepEqual(after.forces, { [f.location]: 16 }); + assert.equal(after.shipped, true); assert.equal(after.moved, 0); + assert.equal(after.noField?.deployed?.tokenId, f.concealedToken); + assert.equal(result.game.karamaShipping, null); + assert.equal(result.game.nexusRicheseCunningLast?.stage, 'shipped'); + assert.equal(result.game.nexusCards!.cards!.discard.filter(card => card === 'richese').length, 1); + assert.equal(result.game.discard.filter(card => card.id === f.karama).length, 1); + assert.equal(player(result.game, f.holder).hand.some(card => card.id === f.karama), false); + assert.deepEqual(inventory(result.game), f.originalInventory); + assert.deepEqual(nexusInventory(result.game), f.originalNexusInventory); + assert.equal(f.sourceActionCount, f.actions.length); + }); +} + +void test('a genuine fourth Guild seat permits the purchased pair but receives none of its bank-paid fee', () => { + const f = fixture({ advanced: true, guild: true }); assert.ok(f.guild && f.guildKarama && f.guildNexusPass); + assert.notEqual(f.guildKarama, f.karama); + assert.deepEqual(f.guildNexusPass.steps[0], { actor: f.guild, action: { + type: 'nexusCardChoice', turn: 2, card: null, choice: 'keep', ownRedraws: 0, + } }); + const guildSpice = player(f.game, f.guild).spice; + const pending = act(f.game, f.owner, f.action); + assert.equal(pending.response?.kind, 'richeseNoField'); + assert.equal(pending.pendingShipment?.cost, 6); + assert.deepEqual(pending.pendingShipment?.richesePair?.karama, f.purchasedRate); + const interception = pass(pending); + assert.equal(interception.decision?.kind, 'guildShipment'); assert.equal(interception.decision?.player, f.guild); + const result = finish(act(interception, f.guild, { type: 'decision', allow: true })); + assert.equal(player(result, f.owner).spice, player(f.game, f.owner).spice - 6); + assert.equal(player(result, f.guild).spice, guildSpice); + assert.ok(player(result, f.guild).hand.some(card => card.id === f.guildKarama)); + assert.equal(result.karamaShipping, null); + assert.equal(result.nexusRicheseCunningLast?.stage, 'shipped'); + assert.notEqual(result.response?.kind, 'guildIncome'); + assert.notEqual(result.response?.kind, 'guildRate'); + assert.deepEqual(inventory(result), f.originalInventory); + assert.deepEqual(nexusInventory(result), f.originalNexusInventory); +}); + +void test('Guild cancelling the original No-Field retains Emperor’s grant for an ordinary bank-rate replacement', () => { + const f = fixture({ advanced: true, guild: true }); assert.ok(f.guild && f.guildCancel); + const before = structuredClone(player(f.game, f.owner)), guildSpice = player(f.game, f.guild).spice; + const pending = act(f.game, f.owner, f.action); + const stopped = finish(act(pending, f.guild, f.guildCancel)), after = player(stopped, f.owner); + assert.equal(after.spice, before.spice); assert.equal(after.reserves, before.reserves); + assert.deepEqual(after.forces, before.forces); assert.deepEqual(after.noField, before.noField); + assert.equal(after.shipped, false); assert.equal(after.moved, 0); + assert.deepEqual(stopped.karamaShipping, f.purchasedRate); + assert.equal(stopped.nexusRicheseCunningLast?.stage, 'stopped'); + assert.equal(stopped.nexusCards!.cards!.hands[f.owner], null); + assert.equal(stopped.nexusCards!.cards!.discard.filter(card => card === 'richese').length, 1); + assert.equal(stopped.discard.filter(card => card.id === f.karama).length, 1); + const replaced = finish(act(stopped, f.owner, f.replacement)); + assert.equal(player(replaced, f.owner).spice, before.spice - 1); + assert.equal(player(replaced, f.owner).reserves, 19); + assert.deepEqual(player(replaced, f.owner).forces, { [f.location]: 1 }); + assert.equal(player(replaced, f.guild).spice, guildSpice); + assert.equal(replaced.karamaShipping, null); + assert.equal(replaced.discard.filter(card => card.id === f.karama).length, 1); + assert.deepEqual(inventory(replaced), f.originalInventory); + assert.deepEqual(nexusInventory(replaced), f.originalNexusInventory); +}); + +void test('native Guild special prevention consumes the pair opportunity and refunds only the unclaimed original activation card', () => { + const f = fixture({ advanced: true, guild: true }); assert.ok(f.guild && f.guildStop && f.guildKarama); + const before = structuredClone(player(f.game, f.owner)); + const interception = pass(act(f.game, f.owner, f.action)); + assert.equal(interception.decision?.kind, 'guildShipment'); + const stopped = finish(act(interception, f.guild, f.guildStop)), after = player(stopped, f.owner); + assert.equal(after.spice, before.spice); assert.equal(after.reserves, before.reserves); + assert.deepEqual(after.forces, before.forces); assert.deepEqual(after.noField, before.noField); + assert.equal(after.shipped, true); assert.equal(after.moved, 0); + assert.equal(stopped.karamaShipping, null); assert.equal(stopped.pendingShipment ?? null, null); + assert.equal(stopped.nexusRicheseCunningLast?.stage, 'stopped'); + assert.equal(stopped.nexusCards!.cards!.discard.filter(card => card === 'richese').length, 1); + assert.ok(player(stopped, f.holder).hand.some(card => card.id === f.karama)); + assert.equal(stopped.discard.filter(card => card.id === f.karama).length, 0); + assert.equal(stopped.discard.filter(card => card.id === f.guildKarama).length, 1); + assert.equal(player(stopped, f.guild).specialKaramaUsed, true); + assert.deepEqual(inventory(stopped), f.originalInventory); + assert.deepEqual(nexusInventory(stopped), f.originalNexusInventory); +}); + +void test('one paid companion and legacy zero-companion city pairs each cost one and still route the purchased fee to Bank', () => { + for (const companions of [0, 1]) { + const f = fixture({ companions, guild: true }); assert.ok(f.guild); + const guildSpice = player(f.game, f.guild).spice; + assert.equal(f.cost, 1); + const result = commit(f.game, f), saved = frame(result.game), after = player(result.game, f.owner); + assert.deepEqual(saved.richesePair.karama, f.purchasedRate); + assert.equal(saved.cost, 1); + assert.equal(player(f.game, f.owner).spice - after.spice, 1); + assert.equal(after.reserves, 20 - (companions + 5)); + assert.deepEqual(after.forces, { [f.location]: companions + 5 }); + assert.equal(player(result.game, f.guild).spice, guildSpice); + assert.equal(result.game.karamaShipping, null); + assert.deepEqual(inventory(result.game), f.originalInventory); + assert.deepEqual(nexusInventory(result.game), f.originalNexusInventory); + } +}); + +void test('CONTROLLED claimed-cost metadata preserves the actual rate identity but prevents another activation-card refund', () => { + const f = fixture({ advanced: true, guild: true, costClaimed: true }); assert.ok(f.guild && f.guildStop && f.guildKarama); + assert.equal(f.controlledCostClaimed, true); assert.equal(f.game.karamaShipping?.costClaimed, true); + assert.ok(f.staging.some(line => line.startsWith('CONTROLLED SUPPLEMENT:'))); + const pending = act(f.game, f.owner, f.action); + assert.equal(pending.pendingShipment?.cost, 6); + assert.deepEqual(pending.pendingShipment?.richesePair?.karama, + { player: f.owner, owner: f.holder, card: f.karama }); + const interception = pass(pending); assert.equal(interception.decision?.kind, 'guildShipment'); + const stopped = finish(act(interception, f.guild, f.guildStop)); + assert.equal(player(stopped, f.owner).reserves, 20); + assert.deepEqual(player(stopped, f.owner).forces, {}); + assert.equal(player(stopped, f.holder).hand.some(card => card.id === f.karama), false); + assert.equal(stopped.discard.filter(card => card.id === f.karama).length, 1); + assert.equal(stopped.discard.filter(card => card.id === f.guildKarama).length, 1); + assert.equal(stopped.karamaShipping, null); + assert.deepEqual(inventory(stopped), f.originalInventory); +}); + +void test('CONTROLLED pending-origin changes reject even when the city invoice remains one spice', () => { + const f = fixture({ companions: 0, guild: true }); assert.ok(f.guild && f.guildKarama); + const pending = act(f.game, f.owner, f.action); + assert.equal(pending.response?.kind, 'richeseNoField'); assert.equal(pending.pendingShipment?.cost, 1); + for (const changed of [null, { ...f.purchasedRate, owner: f.guild }, { ...f.purchasedRate, card: f.guildKarama }]) { + // CONTROLLED metadata edit to an actually declared native pending pair; + // no card, force, phase or original fixture source is constructed here. + const controlled = structuredClone(pending); controlled.karamaShipping = changed; + reject(controlled, f.owner, { type: 'passResponse' }); + } + const legacy: Action = { type: 'ship', noField: f.concealedToken, event: f.event, + revealedToken: f.revealedToken, nexus: f.nexusEvent, territory: f.territory, sector: f.sector, allyPayment: 0 }; + const originallyFullPrice = act(f.beforePurchase, f.owner, legacy); + assert.equal(originallyFullPrice.pendingShipment?.cost, 1); + assert.equal(originallyFullPrice.pendingShipment?.richesePair?.karama, undefined); + // CONTROLLED later card-omitted legacy grant. The pair itself was genuinely + // declared without a purchase; no physical card moves or gets duplicated. + const later = structuredClone(originallyFullPrice); + later.karamaShipping = { player: f.owner, owner: f.holder }; + reject(later, f.owner, { type: 'passResponse' }, /original purchased Karama rate source/); +}); + +void test('the shared payment filter accepts a real sixteen-companion group affordable only under its purchased rate', () => { + const f = fixture(), action = { ...f.action, noFieldReserves: 16 }; + assert.equal(player(f.game, f.owner).spice, 12); + assert.equal(botHomeworldShipmentPaymentAllowed(viewGame(f.game, f.owner), action), true); + assert.equal(botHomeworldShipmentPaymentAllowed(viewGame(f.beforePurchase, f.owner), action), false); + const result = commit(f.game, f, action); + assert.equal(player(result.game, f.owner).spice, 3); + assert.equal(player(result.game, f.owner).reserves, 0); + assert.deepEqual(player(result.game, f.owner).forces, { [f.location]: 20 }); + assert.equal(result.game.karamaShipping, null); +}); + +void test('all four existing profiles retain legal positive Cunning pair choices under the actual purchased rate', () => { + const f = fixture(); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(f.game, f.owner); view.players.find(player => player.id === f.owner)!.bot = difficulty; + const action = botActions(view).find(action => action.type === 'ship' && action.revealedToken && + action.nexus === f.nexusEvent && typeof action.noFieldReserves === 'number' && action.noFieldReserves > 0); + assert.ok(action, `${difficulty} proposes a legal positive paid-group pair using the original purchased grant`); + const revealed = player(f.game, f.owner).noField!.tokens.find(token => token.id === action.revealedToken); assert.ok(revealed); + const quote = quoteRicheseHomeworldPair({ enabled: true, reserves: 20, + amount: Number(action.noFieldReserves), revealedValue: revealed.value }); + const cost = reserveShipmentCost({ faction: 'richese', halfRate: true }, territory(String(action.territory)).type, quote.priced); + const result = commit(structuredClone(f.game), f, action); + assert.equal(player(result.game, f.owner).spice, player(f.game, f.owner).spice - cost); + assert.equal(player(result.game, f.owner).reserves, 20 - quote.physical); + assert.equal(result.game.karamaShipping, null); + assert.equal(result.game.nexusRicheseCunningLast?.stage, 'shipped'); + assert.deepEqual(inventory(result.game), f.originalInventory); + assert.deepEqual(nexusInventory(result.game), f.originalNexusInventory); + } +}); diff --git a/tests/richese-cunning-rate.test.ts b/tests/richese-cunning-rate.test.ts new file mode 100644 index 00000000..2fdc98b0 --- /dev/null +++ b/tests/richese-cunning-rate.test.ts @@ -0,0 +1,84 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + sourceForRicheseCunning, validRicheseCunningRateSource, richeseCunningRateMatches, + RicheseCunningRateError, type RicheseCunningRateSource, type RicheseCunningRateGrant, +} from '../game/richese-cunning-rate'; + +const grant = (patch: Partial = {}): RicheseCunningRateGrant => ({ + player: 'richese', owner: 'emperor', card: 'original-karama', ...patch, +}); + +void test('source snapshots clone only the exact origin without changing live cost-claim history', () => { + const active = Object.freeze(grant({ costClaimed: true })), before = structuredClone(active); + const source = sourceForRicheseCunning(active, 'richese'); + assert.deepEqual(source, { player: 'richese', owner: 'emperor', card: 'original-karama' }); + assert.notEqual(source, active); + assert.ok(validRicheseCunningRateSource(source)); + assert.deepEqual(active, before); + source!.owner = 'changed-snapshot-owner'; + assert.deepEqual(active, before); +}); + +void test('owner, physical card, beneficiary and lost-grant changes cannot retain a source', () => { + const source = sourceForRicheseCunning(grant(), 'richese'); + assert.equal(richeseCunningRateMatches(source, grant(), 'richese'), true); + for (const active of [grant({ owner: 'other-owner' }), grant({ card: 'replacement-karama' }), + grant({ player: 'other-player' }), null, undefined]) + assert.equal(richeseCunningRateMatches(source, active, 'richese'), false); + assert.equal(richeseCunningRateMatches(source, grant(), 'other-player'), false); + const foreign = sourceForRicheseCunning(grant({ player: 'other-player' }), 'other-player'); + assert.equal(richeseCunningRateMatches(foreign, grant({ player: 'other-player' }), 'richese'), false); +}); + +void test('absent snapshots match only absence of a current grant for that beneficiary', () => { + assert.equal(sourceForRicheseCunning(null, 'richese'), undefined); + assert.equal(sourceForRicheseCunning(undefined, 'richese'), undefined); + const foreign = grant({ player: 'other-player' }), before = structuredClone(foreign); + assert.equal(sourceForRicheseCunning(foreign, 'richese'), undefined); + for (const active of [null, undefined, foreign]) + assert.equal(richeseCunningRateMatches(undefined, active, 'richese'), true); + assert.equal(richeseCunningRateMatches(undefined, grant(), 'richese'), false); + assert.deepEqual(foreign, before); +}); + +void test('a later claimed-cost flag leaves origin matching intact and remains sticky on the live grant', () => { + const active = grant(), source = sourceForRicheseCunning(active, 'richese'); + assert.equal(richeseCunningRateMatches(source, active, 'richese'), true); + active.costClaimed = true; + const claimed = structuredClone(active); + assert.equal(richeseCunningRateMatches(source, active, 'richese'), true); + assert.deepEqual(sourceForRicheseCunning(active, 'richese'), source); + assert.deepEqual(active, claimed); + assert.equal(active.costClaimed, true); + assert.ok(!Object.hasOwn(source!, 'costClaimed')); +}); + +void test('serialized legacy grants may omit the original card but cannot silently acquire a replacement origin', () => { + const active: RicheseCunningRateGrant = { player: 'richese', owner: 'emperor' }; + const source = JSON.parse(JSON.stringify(sourceForRicheseCunning(active, 'richese'))) as RicheseCunningRateSource; + assert.ok(validRicheseCunningRateSource(source)); + assert.deepEqual(source, active); + assert.equal(richeseCunningRateMatches(source, { ...active, costClaimed: true }, 'richese'), true); + assert.equal(richeseCunningRateMatches(source, grant(), 'richese'), false); + assert.equal(richeseCunningRateMatches(sourceForRicheseCunning(grant(), 'richese'), active, 'richese'), false); + assert.deepEqual(sourceForRicheseCunning({ ...active, card: undefined }, 'richese'), active); +}); + +void test('malformed or expanded snapshots reject rather than matching by beneficiary or invoice alone', () => { + const bad: unknown[] = [null, [], {}, { player: 'richese' }, { owner: 'emperor' }, + { player: '', owner: 'emperor' }, { player: 'richese', owner: ' ' }, + { player: 'richese', owner: 'emperor', card: '' }, + { player: 'richese', owner: 'emperor', card: undefined }, + { ...grant(), costClaimed: true }, { ...grant(), price: 1 }, { ...grant(), discard: [] }]; + for (const source of bad) { + assert.equal(validRicheseCunningRateSource(source), false); + assert.equal(richeseCunningRateMatches(source as RicheseCunningRateSource, grant(), 'richese'), false); + } + for (const active of [grant({ owner: '' }), grant({ card: ' ' }), grant({ player: '' })]) { + const before = structuredClone(active); + assert.throws(() => sourceForRicheseCunning(active, 'richese'), RicheseCunningRateError); + assert.equal(richeseCunningRateMatches(undefined, active, 'richese'), false); + assert.deepEqual(active, before); + } +});