From 40c31a46e4109f6879da3971b0d952a747a9470c Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:49:54 +1100 Subject: [PATCH] Offer Semuta on native market and allied revival cancellation costs --- docs/CURRENT_STATUS.md | 37 +++ docs/IMPLEMENTATION_STATUS.md | 34 +++ docs/SEMUTA_SOURCE_UPDATE_20260907.md | 78 +++++++ game/engine.ts | 85 ++++++- game/reference.ts | 7 + tests/fixture-semuta-fremen-grant-cost.ts | 197 ++++++++++++++++ tests/fixture-semuta-ix-ally-cost.ts | 191 ++++++++++++++++ tests/fixture-semuta-native-markets.ts | 198 ++++++++++++++++ tests/semuta-fremen-grant-cost-engine.test.ts | 167 ++++++++++++++ tests/semuta-ix-ally-cost-engine.test.ts | 169 ++++++++++++++ tests/semuta-native-markets-engine.test.ts | 215 ++++++++++++++++++ 11 files changed, 1367 insertions(+), 11 deletions(-) create mode 100644 tests/fixture-semuta-fremen-grant-cost.ts create mode 100644 tests/fixture-semuta-ix-ally-cost.ts create mode 100644 tests/fixture-semuta-native-markets.ts create mode 100644 tests/semuta-fremen-grant-cost-engine.test.ts create mode 100644 tests/semuta-ix-ally-cost-engine.test.ts create mode 100644 tests/semuta-native-markets-engine.test.ts diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 198122d5..bf770a00 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,43 @@ open mode gates. ## Current checkpoint and work +**Semuta on native markets and Fremen allied revival — 26 October. Prototyped / Partial.** +Fresh printed-role cancellation costs now pause through `karamaMarketDiscard` +for CHOAM sales, Richese Black Market and native Ixian ally replacement, and +`karamaFremenGrantDiscard` for Fremen's allied free-revival grant. Pure native +validators retain the declared sale/lot, delivered purchase or original grant. +Claim/pass applies native denial once: no canceled sale payout, replacement +discard/draw or new free-rate permission is pre-applied. Existing persistent +controls and minimal bots serve the new sources; preview gates remain unchanged. + +Three disjoint source lanes and root integration pass **14 files,147/147**, +types/lint/build and **16 native engine/GameTable SSR/actual Semuta-callback +journeys** on unchanged source. Twenty-five new cases cover eight sources, +claim/pass, allowance, original suffixes, relevant rejection and four minimal +profiles. Real cache1 Semuta and original dealt Karama supply every cost. +CHOAM duplicate3 sources43/46 Basic/Advanced really buy the second exact-name +Shield for1; Basic Worthless2 source39 uses its original card. Denial keeps the +card/witness, revenue0 and native market chooser. Advanced Black Market82 keeps +Richese's original Shield and compulsory cache accounting3-before/2-normal. +Fremen145/169 uses actual paid3 Hagga13 shipment6, real Storm deaths3, turn2 F/E +Nexus pact and completed intrinsic free1. Denial keeps that return and lets the +ally really revive the remaining2 for4; allowance makes those2 free. Native Ix +ally223/245 uses original inspection/HMS setup, real turn2 I/F Nexus pact and +actual ordinary purchase1. Denial retains that delivered card, prior payment +and untouched deck before the native next lot; allowance really discards and +draws once through the original fresh group. All45/57 cards remain physical. +No played hand, wallet, force, pact, loss, income, clock or pending frame is installed. + +Narrow feedback selects actual duplicate Shields: the differently named +projectile cards cannot witness a duplicate sale. No production rule repair or +new interpretation follows. Private source-bound report/logs: +`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-markets-grant-proof/`; +journeys/HTML: adjacent `20261026-semuta-markets-grant-runtime/`. No room/D1/server, +reset/restart, deployment or recovery operation occurs. These are engine, SSR +and component-callback results; DOM/hydration, browser/mobile, wider parents and +combinations, complete modes and deployed/final assurance remain open. +[Source and limits](SEMUTA_SOURCE_UPDATE_20260907.md#native-market-and-allied-revival-grant-cancellation-costs--26-october-2026). + **Semuta on Ecaz/Moritani cancellation costs — 26 October. Prototyped / Partial.** Six native powers now offer their printed-role Karama cost through `karamaEcazMoritaniDiscard`: Ecaz/Moritani placement, Moritani Duke acquisition, diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index d1916903..5c8eece0 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,39 @@ # Dune implementation status +## 26 October 2026 — Semuta on native market and allied revival costs + +**Prototyped / Partial.** Two typed used-cost continuations add CHOAM sale, +Richese Black Market, native Ixian ally replacement and Fremen allied grant. +Pure native validation retains the declaration, paid delivered card or current +rate source. Claim/pass restores and denies once; sale revenue, replacement +discard/draw and granted free rate remain unapplied. Existing persistent +controls/minimal bots serve these sources. Original preview gates remain. +[Source and limits](SEMUTA_SOURCE_UPDATE_20260907.md#native-market-and-allied-revival-grant-cancellation-costs--26-october-2026). + +Three disjoint source lanes and root integration pass **14 files,147/147**, +types/lint/build and **16 native engine/GameTable SSR/actual Semuta-callback +journeys** on unchanged source. Twenty-five new cases cover eight sources, +claim/pass, allowance, exact retention/native suffixes, real post-denial revival, +relevant rejection and four profiles. All costs use original dealt Karama and +actual cache1 Semuta. Duplicate sale43/46 Basic/Advanced actually buys the second +Shield for1; Basic Worthless source39 keeps its original card. Advanced Black +Market82 restores original Shield/cache9/count3-before-cache/2-normal without a +sale. Fremen145/169 earns actual three Storm losses, turn2 F/E pact and intrinsic +free1; denial preserves that return and permits the actual remaining2 for4, +while allowance makes those2 free. Native Ixian ally223/245 keeps original +inspection/HMS/pool setup, actual turn2 I/F pact and purchase1; denial retains +the delivered card/deck, allowance really discards/draws once. All45/57 cards +remain physical; no played state, income, loss or result is installed. + +Narrow feedback uses actual duplicate Shields instead of differently named +projectile faces; production rules require no repair or new interpretation. +Private source-bound report/logs: +`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-markets-grant-proof/`; +journeys/HTML: adjacent `20261026-semuta-markets-grant-runtime/`. No room/D1/server, +reset/restart, deployment or recovery operation occurs. DOM/hydration, +browser/mobile, wider parents/combinations, complete modes and deployed/final +assurance remain open. + ## 26 October 2026 — Semuta on native Ecaz/Moritani power costs **Prototyped / Partial.** One typed used-cost continuation connects native diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index d0be78bb..81b3d627 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -2,6 +2,84 @@ 7 September 2026. Read `docs/SEMUTA_DRUG_ENGINE_AUDIT.md` in full, inspected the physical card again, checked current capacity/recovery code, and freshly searched publisher sources. No project runtime edits. +## Native market and allied revival-grant cancellation costs — 26 October 2026 + +**Prototyped / Partial.** `karamaMarketDiscard` now offers fresh printed-role +Karama stopping a native CHOAM sale, Richese Black Market or Ixian ally +replacement; `karamaFremenGrantDiscard` does the same for Fremen's allied +free-revival grant. Existing persistent neutral controls, private claim/pass and +minimal legal profiles serve these sources. This composes the existing physical +fresh-discard contract with native powers; it adds no publisher ruling. + +Pure [CHOAM sale cancellation](../game/choam-sale-cancellation.ts), +[auction continuation](../game/auction-continuation-quote.ts), original Richese +settlement and [Fremen grant validation](../game/fremen-revival-grant.ts) own +legality and native denial. Preflight does not require the already spent cost +as another held card. It neither sells/pays, draws a replacement, grants a rate +nor advances a chooser/phase. Exact source-qualified admission retains the +pending Ixian replacement or CHOAM market only for its new used-cost subtype. +Subtype-only signatures bind original declaration, paid sale/card/deck, grant, +prior normal-revival counters, resources, pacts and current continuation; older +signature formats retain their bytes. + +After claim/pass the fresh group retires, the original response restores and +native denial executes once: + +- A declared CHOAM sale keeps its card and any witness in hand, earns nothing and + returns the original market with that declaration blocked. Allowance actually + disposes the card and credits its native2/3; only a real resulting fresh group + is handled. Its owner cannot use Semuta to recover their own sale card. +- Richese Black Market follows the original unbid settlement, keeps the offered + physical card and restores the compulsory-cache declaration/count. No bid, + purchase, payment or extra ordinary-pool reduction is invented. Allowance + opens actual bidding; the control programme then submits real zero bids. +- Native Ixian ally denial clears only replacement, retains the delivered paid + card and prior invoice, skips discard/draw and continues the original sale. + Allowance really discards that card; its actual fresh group resolves before + one replacement draw and the original next lot. Cache/absent-faction Nexus + replacement remain their separate native contracts. +- Fremen denial blocks only the original ally's grant for this Revival. Intrinsic + rates, prior free returns and total limits remain. Allowance grants the native + rate without replaying completed returns; subsequent revival uses the shared + original quote/payment path. + +Three disjoint source lanes, bounded native/diff review and root integration pass +**14 files,147/147**, types/lint/build and **16 native engine/GameTable SSR/actual +Semuta-callback journeys** on unchanged source. Twenty-five new cases cover eight +sources, claim/pass, native allowance, exact retained purchases and continuations, +real post-denial revival, relevant rejection and four minimal profiles. + +Every source uses original setup/deal and actual cache1 Semuta. CHOAM duplicate3 +sources43/46 Basic/Advanced earn an exact-name Shield witness through normal +purchase1; Basic original Worthless2 is source39. Advanced Black Market82 offers +Richese's real Shield, retains cache9 and normal count3-before-cache/2-normal. +In Fremen-grant145/169, Emperor ships three normals to Hagga13 for6, loses +those3 to the next Storm, forms F/E at the turn2 Nexus and completes intrinsic +free1. At the grant source, two remain in Tanks with wallet4; denial really +returns2 for4 and keeps free-used1, while allowance returns2 free and reaches +free-used3. Native Ixian ally223/245 uses original Ix inspection/HMS/pool setup, +real turn2 Ix/Fremen pact and paid ordinary purchase1. Denial keeps the exact +paid card/deck; allowance actually discards/draws once. All45/57 Treachery cards +remain physical. No played hand, wallet, force, pact, loss, income, clock, +response, outcome or pending frame is installed. + +Narrow feedback replaces an impossible same-name projectile fixture with actual +duplicate Shields; the base projectile faces have different names. Native rule +calculations and gates need no feedback change. No new mode or combined overlay +is admitted. Private source-bound report/logs: +`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-markets-grant-proof/`; +journeys/HTML: adjacent `20261026-semuta-markets-grant-runtime/`. No room/D1/server, +reset/restart, deployment or recovery operation occurs. DOM/hydration, +browser/mobile, wider parents/combinations, complete modes and production/final +assurance remain open. + +Evidence: [market sources](../tests/fixture-semuta-native-markets.ts), +[market cases](../tests/semuta-native-markets-engine.test.ts), +[Fremen sources](../tests/fixture-semuta-fremen-grant-cost.ts), +[Fremen cases](../tests/semuta-fremen-grant-cost-engine.test.ts), +[Ixian ally sources](../tests/fixture-semuta-ix-ally-cost.ts) and +[Ixian ally cases](../tests/semuta-ix-ally-cost-engine.test.ts). + ## Ecaz and Moritani cancellation costs — 26 October 2026 **Prototyped / Partial.** `karamaEcazMoritaniDiscard` offers the fresh printed-role diff --git a/game/engine.ts b/game/engine.ts index 882f7711..86731f64 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1577,7 +1577,7 @@ export type Game = { stateSignature: string; } | { - kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard' | 'karamaAuctionAdvantageDiscard' | 'karamaTerminalDiscard' | 'karamaMovementDiscard' | 'karamaRevivalDiscard' | 'karamaDisasterDiscard' | 'karamaCombatDiscard' | 'karamaBonusDiscard' | 'karamaNoFieldDiscard' | 'karamaAlliedTraitorDiscard' | 'karamaEcazMoritaniDiscard'; + kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard' | 'karamaAuctionAdvantageDiscard' | 'karamaTerminalDiscard' | 'karamaMovementDiscard' | 'karamaRevivalDiscard' | 'karamaDisasterDiscard' | 'karamaCombatDiscard' | 'karamaBonusDiscard' | 'karamaNoFieldDiscard' | 'karamaAlliedTraitorDiscard' | 'karamaEcazMoritaniDiscard' | 'karamaMarketDiscard' | 'karamaFremenGrantDiscard'; player: string; card: string; use: Extract; @@ -6042,9 +6042,36 @@ function semutaEcazMoritaniKaramaResponse(g: Game, response: ResponseWindow, pla throw error; } } +/** Native markets and the allied free-rate declaration retain their original + * payment/card/rate sources. Validate denial without selling, drawing or granting. */ +function semutaMarketGrantKaramaResponse(g: Game, response: ResponseWindow, player: string): boolean { + const sale = response.kind === 'choamSale', blackMarket = response.kind === 'richeseBlackMarket', + replacement = response.kind === 'ixAllyCard', grant = response.kind === 'fremenRevivalGrant'; + if ((!sale && !blackMarket && !replacement && !grant) || + g.battle || g.decision || g.phaseOpening || g.pendingKarama || g.truthtrance || + g.pendingExchange || g.pendingNullentropy || g.pendingRicheseGift || g.pendingRichesePurchaseIncome || + g.pendingRevival || g.pendingShipment || g.pendingGuildTransport || g.pendingHomeworldShipment || + g.pendingFremenMove || g.pendingIxMove || g.pendingMobileMove || g.pendingChoamMove || + g.pendingSpiritualAdvisor || g.pendingAmbassador || g.pendingTerrorEntry || g.pendingArrivalOverlap || + g.pendingArrivalReaction?.length || g.ornithopter || g.summonedWorm || g.wormRides.length > 0 || + g.pendingChoamMarketGhola || (!!g.choamMarket !== sale) || (!!g.pendingIxAlly !== replacement)) return false; + if (replacement ? g.phase !== 3 || !g.currentAuctionSale + : blackMarket ? g.phase !== 3 || !g.advanced || g.richeseAuction?.source !== 'blackMarket' || + !!g.auction || !!g.currentAuctionSale + : !!g.auction || !!g.richeseAuction || !!g.currentAuctionSale) return false; + const context: Game = {...g,pendingTreacheryDiscard:null,response}; + try { + // This is the spent physical cost, not a second unspent-card requirement. + validateKaramaUse(context,getPlayer(context,player),{kind:'cancel',response}); + return true; + } catch (error) { + if (error instanceof RuleError) return false; + throw error; + } +} type KaramaResponseDiscardContinuation = Extract< NonNullable['continuation'], - { kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard' | 'karamaAuctionAdvantageDiscard' | 'karamaTerminalDiscard' | 'karamaMovementDiscard' | 'karamaRevivalDiscard' | 'karamaDisasterDiscard' | 'karamaCombatDiscard' | 'karamaBonusDiscard' | 'karamaNoFieldDiscard' | 'karamaAlliedTraitorDiscard' | 'karamaEcazMoritaniDiscard' } + { kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard' | 'karamaAuctionAdvantageDiscard' | 'karamaTerminalDiscard' | 'karamaMovementDiscard' | 'karamaRevivalDiscard' | 'karamaDisasterDiscard' | 'karamaCombatDiscard' | 'karamaBonusDiscard' | 'karamaNoFieldDiscard' | 'karamaAlliedTraitorDiscard' | 'karamaEcazMoritaniDiscard' | 'karamaMarketDiscard' | 'karamaFremenGrantDiscard' } >; function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinuation) { const battle = c.kind === 'karamaBattleDiscard'; @@ -6064,6 +6091,7 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu const benefit = c.kind === 'karamaBonusDiscard' || c.kind === 'karamaNoFieldDiscard' || c.kind === 'karamaAlliedTraitorDiscard'; const e3 = c.kind === 'karamaEcazMoritaniDiscard'; + const marketGrant = c.kind === 'karamaMarketDiscard' || c.kind === 'karamaFremenGrantDiscard'; return JSON.stringify({ player: c.player, card: c.card, use: c.use, parent: nullentropyParentSignature(g, c.resume, @@ -6250,12 +6278,28 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu moved:p.moved, advisors:p.advisors, noField:p.noField, noFieldEvent:p.noFieldEvent, allySince:p.allySinceTurn, battleLosses:p.battleLosses})), } : undefined, + marketGrant: marketGrant ? { + market:g.choamMarket, closing:g.biddingEnd, mentat:g.choamMentatPending, + auction:g.auction, sale:g.currentAuctionSale, ix:g.ixAuction, replacement:g.pendingIxAlly, + round:g.richeseBidding, lot:g.richeseAuction, cache:g.richeseCache, deck:g.deck, + free:g.freeRevival, rules:g.revivalRules, requests:g.revivalRequests, + freeIncome:g.revivalFreeIncome, recruits:g.recruits, extra:g.emperorExtra, + active:g.active, ready:g.ready, remaining:g.movementRemaining, aid:g.aid, + homeworlds:g.homeworlds, occupation:g.homeworldOccupationHistory, + skills:g.leaderSkills, duke:g.dukeVidal, tech:g.techTokens, + banker:g.spiceBankerIncome, bureaucrat:g.bureaucratPayments, + players:g.players.map(p => ({id:p.id, leaders:p.leaders, kwisatz:p.kwisatz, + revived:p.revived, freeForcesRevived:p.freeForcesRevived, leaderRevived:p.leaderRevived, + revivalCycle:p.revivalCycle, gholaBlocked:p.gholaBlocked, ecazCohort:p.ecazRevivalCohort, + moved:p.moved, advisors:p.advisors, noField:p.noField, noFieldEvent:p.noFieldEvent, + allySince:p.allySinceTurn})), + } : undefined, order: g.order, players: g.players.map(p => ({ id: p.id, faction: p.faction, ally: p.ally, hand: p.hand.map(card => card.id), spice: p.spice, - shipped: income || shipment || conversion || terminal || movement || disaster || combat || benefit || e3 ? p.shipped : undefined, - physical: battle || worthless || income || shipment || conversion || terminal || movement || revival || disaster || combat || benefit || e3 + shipped: income || shipment || conversion || terminal || movement || disaster || combat || benefit || e3 || marketGrant ? p.shipped : undefined, + physical: battle || worthless || income || shipment || conversion || terminal || movement || revival || disaster || combat || benefit || e3 || marketGrant ? { forces: p.forces, reserves: p.reserves, tanks: p.tanks, elites: p.elites, leaders: battle ? p.leaders : undefined, noField: shipment ? p.noField : undefined, @@ -6836,7 +6880,8 @@ function treacheryDiscardIntegrity(g: Game) { !g.pendingKarama && (!g.pendingNullentropy || continuation?.kind === 'karamaRicheseGiftDiscard') && (!g.pendingAmbassador || box || truth || ambassadorTerror || ambassadorCost || continuation?.kind === 'karamaRicheseGiftDiscard') && - (!g.pendingIxAlly || box || truth || continuation?.kind === 'karamaRicheseGiftDiscard'), + (!g.pendingIxAlly || box || truth || continuation?.kind === 'karamaRicheseGiftDiscard' || + continuation?.kind === 'karamaMarketDiscard' && continuation.use.response.kind === 'ixAllyCard'), 'The saved discard does not match its current continuation.', ); const ids = new Set(); @@ -7181,6 +7226,8 @@ function treacheryDiscardIntegrity(g: Game) { continuation?.kind === 'karamaNoFieldDiscard' || continuation?.kind === 'karamaAlliedTraitorDiscard' || continuation?.kind === 'karamaEcazMoritaniDiscard' || + continuation?.kind === 'karamaMarketDiscard' || + continuation?.kind === 'karamaFremenGrantDiscard' || continuation?.kind === 'karamaShipmentDiscard' || continuation?.kind === 'karamaBgConversionCancelDiscard') { const c = continuation, entry = batch.entries[0]; @@ -7227,6 +7274,9 @@ function treacheryDiscardIntegrity(g: Game) { semutaBonusNoFieldTraitorResponse(g,c.use.response,c.player)) || (c.kind === 'karamaEcazMoritaniDiscard' && semutaEcazMoritaniKaramaResponse(g,c.use.response,c.player)) || + (((c.kind === 'karamaMarketDiscard' && ['choamSale','richeseBlackMarket','ixAllyCard'].includes(c.use.response.kind)) || + (c.kind === 'karamaFremenGrantDiscard' && c.use.response.kind === 'fremenRevivalGrant')) && + semutaMarketGrantKaramaResponse(g,c.use.response,c.player)) || (c.kind === 'karamaShipmentDiscard' && semutaGuildShipmentKaramaResponse(g, c.use.response)) || (c.kind === 'karamaBgConversionCancelDiscard' && @@ -8140,6 +8190,10 @@ function semutaOfferSupported( const alliedTraitorDiscard = continuation.kind === 'karamaAlliedTraitorDiscard'; const benefitDiscard = bonusDiscard || noFieldDiscard || alliedTraitorDiscard; const e3Discard = continuation.kind === 'karamaEcazMoritaniDiscard'; + const marketGrantDiscard = continuation.kind === 'karamaMarketDiscard' || continuation.kind === 'karamaFremenGrantDiscard'; + const saleCostDiscard = continuation.kind === 'karamaMarketDiscard' && continuation.use.response.kind === 'choamSale'; + const replacementCostDiscard = continuation.kind === 'karamaMarketDiscard' && continuation.use.response.kind === 'ixAllyCard'; + const auctionCostDiscard = replacementCostDiscard || continuation.kind === 'karamaMarketDiscard' && continuation.use.response.kind === 'richeseBlackMarket'; const occupyDiscard = e3Discard && continuation.use.response.kind === 'ecazOccupy'; const terrorAllianceDiscard = e3Discard && continuation.use.response.kind === 'moritaniAlliance'; const ambassadorBenefitDiscard = (bonusDiscard && continuation.use.response.source === 'ambassador') || @@ -8182,7 +8236,7 @@ function semutaOfferSupported( continuation.kind !== 'karamaChoamWorthlessDiscard' && continuation.kind !== 'karamaEmperorShareDiscard' && continuation.kind !== 'karamaIncomeDiscard' && - !richeseDiscard && !auctionAdvantageDiscard && !terminalDiscard && !movementDiscard && !revivalDiscard && !disasterDiscard && !combatDiscard && !benefitDiscard && !e3Discard && + !richeseDiscard && !auctionAdvantageDiscard && !terminalDiscard && !movementDiscard && !revivalDiscard && !disasterDiscard && !combatDiscard && !benefitDiscard && !e3Discard && !marketGrantDiscard && continuation.kind !== 'karamaShipmentDiscard' && continuation.kind !== 'karamaShipmentPriceDiscard' && continuation.kind !== 'specialKaramaDiscard' && @@ -8219,7 +8273,7 @@ function semutaOfferSupported( continuation.promise.kind === 'shipment' || continuation.promise.kind === 'movement' || continuation.promise.kind === 'battle'))) return false; - if (g.homeworlds && !bgDiscard && !richeseDiscard && !auctionAdvantageDiscard && !terminalDiscard && !movementDiscard && !revivalDiscard && !disasterDiscard && !combatDiscard && !benefitDiscard && !e3Discard && continuation.kind !== 'kaitainDiscard' && + if (g.homeworlds && !bgDiscard && !richeseDiscard && !auctionAdvantageDiscard && !terminalDiscard && !movementDiscard && !revivalDiscard && !disasterDiscard && !combatDiscard && !benefitDiscard && !e3Discard && !marketGrantDiscard && continuation.kind !== 'kaitainDiscard' && continuation.kind !== 'tupileCleanupDiscard' && continuation.kind !== 'karamaIncomeDiscard' && continuation.kind !== 'karamaShipmentDiscard' && @@ -8330,6 +8384,7 @@ function semutaOfferSupported( combatDiscard || benefitDiscard || e3Discard || + marketGrantDiscard || continuation.kind === 'karamaShipmentDiscard' ? (continuation.kind === 'karamaBattleDiscard' ? semutaBattleKaramaResponse(g, continuation.use.response) @@ -8353,6 +8408,8 @@ function semutaOfferSupported( ? semutaDisasterKaramaResponse(g,continuation.use.response,continuation.player) : combatDiscard ? semutaCombatKaramaResponse(g,continuation.use.response,continuation.player,continuation.resume.decision) + : marketGrantDiscard + ? semutaMarketGrantKaramaResponse(g,continuation.use.response,continuation.player) : e3Discard ? semutaEcazMoritaniKaramaResponse(g,continuation.use.response,continuation.player) : benefitDiscard @@ -8399,6 +8456,7 @@ function semutaOfferSupported( combatDiscard || benefitDiscard || e3Discard || + marketGrantDiscard || continuation.kind === 'karamaShipmentDiscard' || ((continuation.kind === 'ordinaryCardDiscard' ? ordinaryDiscardIncomeMatches(g, continuation) @@ -8411,7 +8469,7 @@ function semutaOfferSupported( : !continuation.resume.phaseOpening)))) && (richeseGiftDiscard || continuation.kind === 'karamaShipmentDiscard' || continuation.kind === 'specialKaramaDiscard' || bgDiscard || !g.karamaShipping) && - (bonusDiscard || auctionAdvantageDiscard || richeseGiftDiscard || bgDiscard || continuation.kind === 'specialKaramaDiscard' || (continuation.kind === 'ixAllyCard' + (auctionCostDiscard || bonusDiscard || auctionAdvantageDiscard || richeseGiftDiscard || bgDiscard || continuation.kind === 'specialKaramaDiscard' || (continuation.kind === 'ixAllyCard' ? !!g.currentAuctionSale && continuation.sale.origin === g.currentAuctionSale.origin && (continuation.sale.origin === 'normal' ? !!g.auction && !g.richeseAuction @@ -8425,7 +8483,7 @@ function semutaOfferSupported( ? !!g.currentAuctionSale && (g.currentAuctionSale.origin === 'normal' ? !!g.auction && !g.richeseAuction : !!g.richeseAuction && !g.auction) : (continuation.kind === 'saphoAuctionDiscard' && continuation.cyclic === 'normal') || !g.auction)) && - (bonusDiscard || auctionAdvantageDiscard || richeseGiftDiscard || bgDiscard || continuation.kind === 'specialKaramaDiscard' || continuation.kind === 'saphoAuctionDiscard' || + (auctionCostDiscard || bonusDiscard || auctionAdvantageDiscard || richeseGiftDiscard || bgDiscard || continuation.kind === 'specialKaramaDiscard' || continuation.kind === 'saphoAuctionDiscard' || continuation.kind === 'ixAllyCard' || (continuation.kind === 'karamaIncomeDiscard' && continuation.use.response.kind === 'emperorIncome') || !g.richeseAuction) && @@ -8452,7 +8510,7 @@ function semutaOfferSupported( (!!g.stormResolution && !g.pendingChoamMove && !g.pendingArrivalOverlap && !g.pendingRevival && !g.ornithopter && !g.ecologicalStorm)) && - (richeseGiftDiscard || ambassadorBenefitDiscard || (!g.pendingExchange && !g.pendingIxAlly && !g.pendingNullentropy && + (richeseGiftDiscard || ambassadorBenefitDiscard || (!g.pendingExchange && (replacementCostDiscard || !g.pendingIxAlly) && !g.pendingNullentropy && !g.pendingAmbassador && (terrorAllianceDiscard || !g.pendingTerrorEntry))) && (richeseGiftDiscard || noFieldDiscard || continuation.kind === 'karamaShipmentDiscard' || continuation.kind === 'specialKaramaDiscard' || @@ -8461,7 +8519,7 @@ function semutaOfferSupported( (richeseGiftDiscard || !g.pendingRicheseGift) && (richeseGiftDiscard || (continuation.kind === 'karamaIncomeDiscard' && continuation.use.response.kind === 'richesePurchaseIncome') || !g.pendingRichesePurchaseIncome) && - (richeseGiftDiscard || (continuation.kind === 'choamSaleDiscard' + (richeseGiftDiscard || saleCostDiscard || (continuation.kind === 'choamSaleDiscard' ? !!g.choamMarket && !g.choamMarket.sale && !g.choamMarket.trade && !g.biddingEnd && !g.currentAuctionSale : continuation.kind === 'kaitainDiscard' @@ -8764,6 +8822,8 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string next.kind === 'karamaNoFieldDiscard' || next.kind === 'karamaAlliedTraitorDiscard' || next.kind === 'karamaEcazMoritaniDiscard' || + next.kind === 'karamaMarketDiscard' || + next.kind === 'karamaFremenGrantDiscard' || next.kind === 'karamaShipmentDiscard' || next.kind === 'karamaBgConversionCancelDiscard' || next.kind === 'karamaPurchaseDiscard' || @@ -16138,6 +16198,7 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered semutaCombatKaramaResponse(g,use.response,p.id) || semutaBonusNoFieldTraitorResponse(g,use.response,p.id) || semutaEcazMoritaniKaramaResponse(g,use.response,p.id) || + semutaMarketGrantKaramaResponse(g,use.response,p.id) || guildShipmentDiscard || bgConversionCancelDiscard)) { const continuation: KaramaResponseDiscardContinuation = { kind: bgConversionCancelDiscard ? 'karamaBgConversionCancelDiscard' @@ -16154,6 +16215,8 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered : use.response.kind === 'richeseNoField' ? 'karamaNoFieldDiscard' : use.response.kind === 'harkonnenTraitor' ? 'karamaAlliedTraitorDiscard' : ['ecazPlacement','moritaniPlacement','moritaniDuke','ecazCollection','ecazOccupy','moritaniAlliance'].includes(use.response.kind) ? 'karamaEcazMoritaniDiscard' + : ['choamSale','richeseBlackMarket','ixAllyCard'].includes(use.response.kind) ? 'karamaMarketDiscard' + : use.response.kind === 'fremenRevivalGrant' ? 'karamaFremenGrantDiscard' : use.response.kind === 'choamCharity' ? 'karamaCharityDiscard' : use.response.kind === 'choamInflation' ? 'karamaInflationDiscard' diff --git a/game/reference.ts b/game/reference.ts index 29d74b83..7b1c9d32 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1296,6 +1296,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Printed-role Karama stopping native Voice, ordinary Prescience, Kwisatz Haderach, elite strength, Fremen support or CHOAM battle aid offers its exact used cost while the original battle waits. Claim or Continue finishes that prevention once, then native preparation or the next power check resumes. The offer does not answer Prescience, seal or reveal plans, charge support, spend a reserved pledge or change the forces. Taking Karama does not restore the canceled benefit. The separate live Nexus borrowed-Voice parent retains its existing cancellation path; wider combinations remain unfinished.', 'Karama stopping Harkonnen’s extra card after a purchase, a Richese No-Field declaration or Harkonnen’s Traitor call for an ally also offers its used cost. Claim or Continue keeps the original purchase paid and prevents only the bonus draw. A denied No-Field moves no token or forces and charges no shipment; the original ordinary shipment or independent Ambassador reserve grant remains available. An allied Traitor cancellation preserves revealed plans and earlier votes, changes only Harkonnen’s effective call, then finishes the original battle once. The physical Traitor is never a Semuta target. Supported Ambassador cases are Basic; broader combinations remain unfinished.', 'Karama stopping native Ecaz or Moritani placement, Moritani’s earned Duke acquisition, Ecaz Occupy or Enemy of My Enemy also offers its used cost. Claim or Continue keeps earlier payments, physical tokens, the selected battle lead and paid entry intact, then applies the original denial once. Moritani may still reveal or decline its Terror token. Ecaz’s shared-stronghold Collection bonus is Advanced-only: a restricted fresh development setup offers this cost before ordinary collection settles, withholding only the canceled bonus. Basic collection has no such bonus; public Advanced Semuta starts and wider combinations remain guarded.', + 'Karama stopping a native CHOAM sale, Richese Black Market, Ixian ally replacement or Fremen allied free-revival grant also offers its fresh used cost. Claim or Continue applies the original denial once: a canceled sale keeps its unsold card and earns nothing; Black Market returns through its unbid continuation; replacement keeps the original paid card and skips the exchange; the ally retains intrinsic revival rates and completed returns. Allowance still uses the original sale, auction, draw or grant controls. Only the used Treachery cost is fresh at this response.', 'Printed-role Karama stopping ordinary Basic Spiritual Advisors, Atreides Movement foresight, Advanced Fremen Storm knowledge or native Face Dancer replacement also offers its exact used cost. Claim or Continue finishes that original prevention once. The paid shipment and remaining turn stay intact; the stopped free packet is not placed. Foresight gains no new knowledge or draw. An original Bene Gesserit stance or Guild opening choice waits unchanged until the Atreides cost finishes. A stopped replacement retains the original Face Dancers and reserve, with its attempt still used. Storm opening acknowledgements must finish before cancellation; they are never bypassed or repeated. Complex Spiritual and arrival parents remain separate.', 'Printed-role Karama stopping ordinary native Fremen range, Cyborg range, Advanced Guild timing or HMS movement/collection also offers its exact used cost. Claim or Continue completes the original denial once. Fremen and Ixian forces remain in place with their move unused and current-use restriction intact; choose a legal replacement. Guild follows the original remaining queue under its native timing lock. A canceled HMS route neither relocates nor collects; only after the fresh cost finishes does the original Storm turn begin, including its normal Advanced card handling. No declaration, payment, movement, collection or turn opening repeats. Card-flight and other complex parents retain their separate boundaries.', 'The explicit fresh Leader Skills entry composes Semuta with original all-fourteen-card native Basic Richese/CHOAM or Advanced E1/E2 setup, retaining original Tech and Advanced Stronghold prerequisites. Actual offers train original living discs; the native Auditor remains ineligible. A completed Hajr discard can resume the same Movement owner before an original private Mentat question. Mandatory loser disposal can resume trained Suk casualty rescue or Rihani’s normal private inspection and optional skilled exchange before winner cleanup. Winning compulsory Artillery stays held through the original skill, then becomes its own fresh Treachery discard. Traitor/Face Dancer movement is never a Semuta target; no rescue, question, inspection or exchange repeats. Basic Ix mixtures, E3, other module/Banker overlays and public starts stay separate.', @@ -1461,6 +1462,12 @@ export const RULE_TOPICS: RuleTopic[] = [ 'tests/semuta-ecaz-economy-engine.test.ts', 'tests/fixture-semuta-moritani-alliance.ts', 'tests/semuta-moritani-alliance-engine.test.ts', + 'tests/fixture-semuta-native-markets.ts', + 'tests/semuta-native-markets-engine.test.ts', + 'tests/fixture-semuta-fremen-grant-cost.ts', + 'tests/semuta-fremen-grant-cost-engine.test.ts', + 'tests/fixture-semuta-ix-ally-cost.ts', + 'tests/semuta-ix-ally-cost-engine.test.ts', 'tests/fixture-semuta-spiritual-foresight.ts', 'tests/semuta-spiritual-foresight-engine.test.ts', 'tests/fixture-semuta-storm-dancer.ts', diff --git a/tests/fixture-semuta-fremen-grant-cost.ts b/tests/fixture-semuta-fremen-grant-cost.ts new file mode 100644 index 00000000..2619314f --- /dev/null +++ b/tests/fixture-semuta-fremen-grant-cost.ts @@ -0,0 +1,197 @@ +import assert from 'node:assert/strict'; +import { createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { quoteFremenRevivalGrant, validateFremenRevivalGrant } from '../game/fremen-revival-grant'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus'; +import { + actSemutaDisasterWorm, nextSemutaDisasterWorm, passSemutaDisasterWorm, claimSemutaDisasterWorm, + semutaDisasterWormPlayer, semutaDisasterWormInventory, +} from './fixture-semuta-disaster-worm'; + +export type SemutaFremenGrantCostStep = { actor: string; action: Action }; +export const actSemutaFremenGrantCost = actSemutaDisasterWorm; +export const passSemutaFremenGrantCost = passSemutaDisasterWorm; +export const claimSemutaFremenGrantCost = claimSemutaDisasterWorm; +export const semutaFremenGrantCostPlayer = semutaDisasterWormPlayer; +export const semutaFremenGrantCostInventory = semutaDisasterWormInventory; +export const semutaFremenGrantCostClean = (game: Game) => !game.response && !game.phaseOpening && !game.decision && + !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingRevival; +export const semutaFremenGrantCostPhysical = (game: Game) => ({ + players: game.players.map(p => ({ id: p.id, spice: p.spice, forces: p.forces, elites: p.elites, + reserves: p.reserves, tanks: p.tanks, leaders: p.leaders, ally: p.ally, + revived: p.revived, freeForcesRevived: p.freeForcesRevived, leaderRevived: p.leaderRevived, + shipped: p.shipped, moved: p.moved })), + spice: game.spice, revivalFreeIncome: game.revivalFreeIncome, revivalRequests: game.revivalRequests, +}); + +/** Native setup/readiness, real positive first-Storm dials and declined + * ordinary optional controls. No grant, revival or fresh group is chosen. */ +export function nextSemutaFremenGrantCost(game: Game): SemutaFremenGrantCostStep | null { + if (game.pendingTreacheryDiscard || game.truthtrance) return null; + if (!game.response && !game.phaseOpening && + (game.decision?.kind === 'revivalStop' || game.decision?.kind === 'choamFreeRevival')) + return { actor: game.decision.player, action: { type: 'decision', decline: true } }; + const step = nextSemutaDisasterWorm(game); + if (step?.action.type === 'stormDial' && game.turn === 1) + return { actor: step.actor, action: { ...step.action, amount: step.actor === game.stormDialers[0] ? 11 : 0 } }; + return step; +} +export const nextSemutaFremenGrantCostStep = nextSemutaFremenGrantCost; +export function advanceSemutaFremenGrantCost(game: Game, until: (state: Game) => boolean, + actions: SemutaFremenGrantCostStep[] = []): Game { + for (let count = 0; count < 2000; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished'); + const step = nextSemutaFremenGrantCost(game); + assert.ok(step, `Choose the original fresh/private control at ${game.turn}/${game.phase}/${game.decision?.kind}`); + game = actSemutaFremenGrantCost(game, step.actor, step.action, actions); + } + throw Error('The native Fremen grant source did not reach its finite boundary'); +} +export function allowSemutaFremenGrantCost(game: Game, actions: SemutaFremenGrantCostStep[] = []): Game { + assert.equal(game.response?.kind, 'fremenRevivalGrant'); + for (let count = 0; game.response?.kind === 'fremenRevivalGrant' && count < game.players.length; count++) { + const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor); + game = actSemutaFremenGrantCost(game, actor.id, { type: 'passResponse' }, actions); + } + assert.notEqual(game.response?.kind, 'fremenRevivalGrant'); return game; +} +/** Settle only this original grant or an already requested ordinary return; + * never ready the phase, resubmit a request or bypass a fresh cost. */ +export function finishSemutaFremenGrantCost(game: Game, actions: SemutaFremenGrantCostStep[] = []): Game { + assert.equal(game.pendingTreacheryDiscard ?? null, null, 'Choose the original fresh cost first'); + return advanceSemutaFremenGrantCost(game, semutaFremenGrantCostClean, actions); +} + +/** Genuine cache1 Semuta, original printed cost, exposed native casualties, + * real Nexus pact and completed intrinsic free return before the grant. */ +export function createSemutaFremenGrantCostFixture(options: { advanced?: boolean } = {}) { + const advanced = options.advanced ?? false, seller = 'richese', holder = 'choam', owner = 'fremen'; + const recipient = 'emperor', spender = 'guild', territory = 'hagga_basin', sector = 13, location = `${territory}:${sector}`; + let game = createGame('SEMUTAFREMENGRANTCOST', newPlayer(seller, 'Richese', 'richese'), advanced, ['choam']); + for (const faction of ['choam', 'fremen', 'emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction)); + const initial = structuredClone(game), actions: SemutaFremenGrantCostStep[] = []; + const act = (actor: string, action: Action) => { game = actSemutaFremenGrantCost(game, actor, action, actions); }; + const reach = (until: (state: Game) => boolean) => { game = advanceSemutaFremenGrantCost(game, until, actions); }; + const transition = (run: () => void) => { + const before = structuredClone(game), at = actions.length; run(); + return { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) }; + }; + for (const p of game.players) act(p.id, { type: 'ready' }); + const lobby = structuredClone(game); + if (advanced) game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game)); + else act(game.host, { type: 'start' }); + const setup = structuredClone(game), initialization = { before: lobby, after: setup, + entry: advanced ? 'original-undealt-advanced-semuta-preview' : 'public-basic-start' }; + assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced); assert.equal(game.semutaPreview, true); + assert.ok(game.players.every(p => p.hand.length === 0)); + assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens && + !game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery); + const originalInventory = semutaFremenGrantCostInventory(game); assert.equal(originalInventory.length, 45); + const printed = game.deck.find(card => card.effect === 'karama'), shield = game.deck.find(card => card.kind === 'shield'); + const ordinary = game.deck.filter(card => card.kind === 'projectile').slice(0, 3); assert.equal(ordinary.length, 3); + assert.ok(printed && shield); + const selected = [...ordinary, shield, printed], dealOrder = selected.map((card, position) => { + const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position); + [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; + return { position, from, card: card.id }; + }); + const beforeStartingDeal = structuredClone(game); reach(state => state.status === 'playing'); + const afterSetup = structuredClone(game), cost = printed.id; + assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id)); + assert.equal(semutaFremenGrantCostPlayer(game, owner).forces['sietch_tabr:14'], 10); + const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort(); + const spiceOrder: { turn: number; position: number; from: number; card: Game['spiceDeck'][number] }[] = []; + const orderSpice = (faces: string[]) => { + for (let position = 0; position < faces.length; position++) { + const face = faces[position], from = game.spiceDeck.findIndex((card, index) => index >= position && + (face === 'worm' ? 'worm' in card && !card.suppressed && !card.thumper && !card.greatMaker + : 'territory' in card && card.territory === face && !card.discovery)); + assert.ok(from >= position, `Choose the original still-unopened ${face} Spice Card`); + const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card); + spiceOrder.push({ turn: game.turn, position, from, card: structuredClone(card) }); + } + }; + orderSpice(advanced ? ['funeral_plain', 'broken_land'] : ['funeral_plain']); + let beforeBid: Game | undefined, bid: SemutaFremenGrantCostStep | undefined, bidAt = -1; + for (let count = 0; count < 900 && !semutaFremenGrantCostPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) { + const step = nextSemutaFremenGrantCost(game); assert.ok(step); + if (step.actor === holder && step.action.type === 'richeseBid' && step.action.amount === 1) { + beforeBid = structuredClone(game); bid = structuredClone(step); bidAt = actions.length; + } + act(step.actor, step.action); + } + assert.ok(beforeBid && bid && bidAt >= 0); + const acquired = structuredClone(game), acquisition = { before: beforeBid, bid, + steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 }; + assert.equal(semutaFremenGrantCostPlayer(beforeBid, holder).spice - semutaFremenGrantCostPlayer(game, holder).spice, 1); + reach(state => state.turn === 1 && state.phase === 5 && state.active === recipient && semutaFremenGrantCostClean(state)); + assert.equal(game.storm, 12); + const shipment = transition(() => { + act(recipient, { type: 'ship', territory, sector, amount: 3, elite: 0, allyPayment: 0 }); + reach(semutaFremenGrantCostClean); act(recipient, { type: 'endMovement' }); + }); + assert.equal(semutaFremenGrantCostPlayer(shipment.before, recipient).spice - semutaFremenGrantCostPlayer(game, recipient).spice, 6); + assert.equal(semutaFremenGrantCostPlayer(game, recipient).forces[location], 3); + reach(state => state.turn === 2 && state.phase === 0 && semutaFremenGrantCostClean(state)); + const storm = transition(() => { + reach(state => state.turn === 2 && state.phase === 1 && semutaFremenGrantCostClean(state) && + !state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus && state.ready.length === 0); + }); + assert.equal(semutaFremenGrantCostPlayer(game, recipient).tanks, 3); + assert.equal(semutaFremenGrantCostPlayer(game, recipient).forces[location] ?? 0, 0); + orderSpice(['worm', 'the_great_flat', ...(advanced ? ['hagga_basin'] : [])]); + reach(state => state.turn === 2 && state.phase === 1 && state.nexus && !state.spiceWindow && + !state.spiceResolution && semutaFremenGrantCostClean(state)); + const alliance = transition(() => { + act(owner, { type: 'alliance', target: recipient }); act(recipient, { type: 'alliance', target: owner }); + }); + assert.equal(semutaFremenGrantCostPlayer(game, owner).ally, recipient); + assert.equal(semutaFremenGrantCostPlayer(game, recipient).ally, owner); + reach(state => state.turn === 2 && state.phase === 4 && semutaFremenGrantCostClean(state)); + const beforePriorRevival = structuredClone(game), intrinsicBefore = structuredClone(viewGame(game, recipient).revival); + assert.equal(intrinsicBefore.freeRemaining, 1); assert.equal(intrinsicBefore.forcesRemaining, 3); + const priorRevivalAction: Action = { type: 'revive', amount: 1, elite: 0 }; + const priorRevival = transition(() => { + act(recipient, priorRevivalAction); game = finishSemutaFremenGrantCost(game, actions); + }); + assert.equal(semutaFremenGrantCostPlayer(game, recipient).revived, 1); + assert.equal(semutaFremenGrantCostPlayer(game, recipient).freeForcesRevived, 1); + assert.equal(semutaFremenGrantCostPlayer(game, recipient).tanks, 2); + assert.equal(semutaFremenGrantCostPlayer(game, recipient).spice, 4); + const beforeDeclaration = structuredClone(game), intrinsic = structuredClone(viewGame(game, recipient).revival); + assert.equal(intrinsic.freeRemaining, 0); assert.equal(intrinsic.forcesRemaining, 2); + const nativeAction: Action = { type: 'grantRevival' }; + const declaration = transition(() => { act(owner, nativeAction); }); + assert.ok(game.response?.fremenGrant); assert.equal(game.response.kind, 'fremenRevivalGrant'); + assert.equal(game.response.owner, owner); assert.equal(game.response.recipient, recipient); + validateFremenRevivalGrant(game, game.response.fremenGrant); + assert.deepEqual(game.response.fremenGrant, quoteFremenRevivalGrant(game, owner)); + assert.ok(game.revivalRules); + assert.equal(game.pendingRevival ?? null, null); assert.equal(game.decision ?? null, null); + assert.deepEqual(semutaFremenGrantCostPhysical(game), semutaFremenGrantCostPhysical(beforeDeclaration)); + const source = structuredClone(game), originalResponse = structuredClone(game.response), fremenGrant = structuredClone(game.response.fremenGrant); + const action: Action = { type: 'card', card: cost, mode: 'cancel' }; + const replacementAction: Action = { type: 'revive', amount: 2, elite: 0 }; + assert.deepEqual(semutaFremenGrantCostInventory(game), originalInventory); + return { game, source, beforeCost: source, advanced, kind: 'fremenRevivalGrant' as const, seller, richese: seller, + holder, owner, fremen: owner, recipient, ally: recipient, spender, guild: spender, cost, karama: cost, + semuta: SEMUTA_DRUG_ID, action, nativeAction, grantAction: nativeAction, replacementAction, + ordinaryRevivalAction: replacementAction, priorRevivalAction, originalResponse, fremenGrant, + sourceFremenGrant: fremenGrant, originalRules: structuredClone(game.revivalRules), + originalFreeRevival: [...game.freeRevival], originalPhysical: structuredClone(semutaFremenGrantCostPhysical(game)), + intrinsicBefore, intrinsic, replacementAmount: 2, deniedCost: 4, allowedCost: 0, + territory, sector, location, initial, lobby, setup, initialization, beforeStartingDeal, afterSetup, + deal: selected.map(card => card.id), dealOrder, originalInventory, originalSpice, spiceOrder, + acquisition, acquired, shipment, storm, alliance, beforePriorRevival, priorRevival, beforeDeclaration, declaration, + actions, steps: actions, sourceActions: actions, sourceActionCount: actions.length, + staging: [ + advanced ? 'Original Advanced Semuta initializer on the genuinely ready undealt R/C/F/E/G lobby, main35/cache10, no modules.' : 'Actual public Basic R/C/F/E/G ready/start, original main35/cache10 and no modules.', + 'Only unopened original starting-card/Spice positions and scalar lotteries are selected. Guild really receives printed Karama; CHOAM really pays cache1 for Semuta. Fremen actually places native ten at Tabr. Actual first-Storm dials11+0 end at12.', + 'Emperor really pays six for three original normal reserves at exposed Hagga13. The original next Storm crosses13 and kills all three; native Fremen forecast/CHOAM/Guild controls are passed without spending the cancellation card. No casualties or weather cursor are installed.', + 'An actual original turn2 worm on the empty first Funeral pile earns Nexus. Fremen proposes and Emperor accepts their actual mutual pact. At real Revival, Emperor completes its intrinsic free1, earning revived1/freeUsed1 with two Tanks and four spice left, before Fremen declares the original grant.', + 'Stop at the typed native fremenRevivalGrant response with no pendingRevival. No played hand, wallet, force, alliance, clock, free rate, return, rules or pending frame is installed. The same later ordinary2 action costs four if denied and is free if allowed; all prior return/usage remains native.', + ], + }; +} +export type SemutaFremenGrantCostFixture = ReturnType; diff --git a/tests/fixture-semuta-ix-ally-cost.ts b/tests/fixture-semuta-ix-ally-cost.ts new file mode 100644 index 00000000..0628c3ff --- /dev/null +++ b/tests/fixture-semuta-ix-ally-cost.ts @@ -0,0 +1,191 @@ +import assert from 'node:assert/strict'; +import { createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus'; +import { actTruthMovement } from './fixture-truth-movement'; +import { + nextSemutaIxianAuctionStep, semutaIxianAuctionPlayer, + semutaIxianAuctionInventory, semutaIxianAuctionClean, + passSemutaIxianAuction, claimSemutaIxianAuction, +} from './fixture-semuta-ixian-auction'; + +export type SemutaIxAllyCostStep = { actor: string; action: Action }; +export const actSemutaIxAllyCost = actTruthMovement; +export const semutaIxAllyCostPlayer = semutaIxianAuctionPlayer; +export const semutaIxAllyCostInventory = semutaIxianAuctionInventory; +export const semutaIxAllyCostClean = semutaIxianAuctionClean; +export const passSemutaIxAllyCost = passSemutaIxianAuction; +export const claimSemutaIxAllyCost = claimSemutaIxianAuction; + +/** Consume only live controls. The real ally replacement request and paid bid + * remain source-owned actions; fresh physical discards receive actual passes. */ +export function nextSemutaIxAllyCost(game: Game): SemutaIxAllyCostStep | null { + const pending = game.pendingTreacheryDiscard, reaction = pending?.reaction; + if (pending) { + if (reaction?.stage !== 'offer') return null; + const actor = game.players.find(p => !reaction.passed.includes(p.id)); assert.ok(actor); + return { actor: actor.id, action: { type: 'semutaPass', event: pending.batch.event } }; + } + if (!game.response && !game.phaseOpening) { + const fremen = game.players.find(p => p.faction === 'fremen'); + if (game.status === 'setup' && game.setupStage === 'forces' && fremen?.reserves === 20 && + viewGame(game, fremen.id).setupPending.includes(fremen.id)) + return { actor: fremen.id, action: { type: 'fremenSetup', placements: { 'sietch_tabr:14': 10 } } }; + const d = game.decision; + if (d?.kind === 'ixAllyCard') return null; + if (d?.kind === 'wormRide' || d?.kind === 'wormPlacement') + return { actor: d.player, action: { type: 'decision', accept: false } }; + if (d?.kind === 'wormProtection') return { actor: d.player, action: { type: 'decision', accept: true } }; + } + return nextSemutaIxianAuctionStep(game); +} +export const nextSemutaIxAllyCostStep = nextSemutaIxAllyCost; +export function advanceSemutaIxAllyCost(game: Game, until: (state: Game) => boolean, + actions: SemutaIxAllyCostStep[] = []): Game { + for (let count = 0; count < 2600; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished'); + const next = nextSemutaIxAllyCost(game); + assert.ok(next, `Supply the actual Ixian ally purchase choice at ${game.turn}/${game.phase}/${game.decision?.kind}`); + game = actSemutaIxAllyCost(game, next.actor, next.action, actions); + } + throw Error('The original paid Ixian ally source did not reach its finite boundary'); +} +export function allowSemutaIxAllyCost(game: Game, actions: SemutaIxAllyCostStep[] = []): Game { + assert.equal(game.response?.kind, 'ixAllyCard'); + for (let count = 0; game.response?.kind === 'ixAllyCard' && count < game.players.length; count++) { + const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor); + game = actSemutaIxAllyCost(game, actor.id, { type: 'passResponse' }, actions); + } + assert.notEqual(game.response?.kind, 'ixAllyCard'); return game; +} +/** Finish this paid sale only, including a real replacement-card discard + * reaction if allowance opened one. Stop before the next actual bid. */ +export function finishSemutaIxAllyCost(game: Game, actions: SemutaIxAllyCostStep[] = []): Game { + return advanceSemutaIxAllyCost(game, state => !state.currentAuctionSale && + !state.pendingIxAlly && semutaIxAllyCostClean(state), actions); +} + +/** Original R/C/I/F/G setup, cache1, actual worm Nexus pact and ordinary paid + * auction. Neither the delivered card nor a replacement is installed. */ +export function createSemutaIxAllyCostFixture(options: { advanced?: boolean } = {}) { + const advanced = options.advanced ?? false; + const richese = 'richese', holder = 'choam', owner = 'ixians', buyer = 'fremen', spender = 'guild'; + let game = createGame('SEMUTAIXALLYCOST', newPlayer(richese, 'Richese', 'richese'), advanced, ['choam', 'ix']); + for (const faction of ['choam', 'ixians', 'fremen', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction)); + const initial = structuredClone(game), actions: SemutaIxAllyCostStep[] = []; + const act = (actor: string, action: Action) => { game = actSemutaIxAllyCost(game, actor, action, actions); }; + const reach = (until: (state: Game) => boolean) => { game = advanceSemutaIxAllyCost(game, until, actions); }; + const transition = (run: () => void) => { + const before = structuredClone(game), at = actions.length; run(); + return { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) }; + }; + for (const p of game.players) act(p.id, { type: 'ready' }); + const lobby = structuredClone(game); + if (advanced) game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game)); + else act(game.host, { type: 'start' }); + const setup = structuredClone(game); + assert.equal(game.status, 'setup'); assert.equal(game.semutaPreview, true); + assert.ok(game.players.every(p => p.hand.length === 0)); + assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens && + !game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery); + const originalInventory = semutaIxAllyCostInventory(game); assert.equal(originalInventory.length, 57); + const printed = game.deck.find(card => card.effect === 'karama' && !card.id.startsWith('ix-')); + const shield = game.deck.find(card => card.kind === 'shield'); + const ordinary = game.deck.filter(card => card.kind === 'projectile' && !card.effect).slice(0, 3); + assert.ok(printed && shield); assert.equal(ordinary.length, 3); + const selected = [ordinary[0], ordinary[1], shield, ordinary[2], printed]; + const dealOrder = selected.map((card, position) => { + const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position); + [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; + return { card: card.id, position, from }; + }); + const beforeStartingDeal = structuredClone(game), cost = printed.id; + const setupChoices = transition(() => { reach(state => state.status === 'playing'); }); + const afterSetup = structuredClone(game); + assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id)); + assert.ok(semutaIxAllyCostPlayer(game, spender).hand.some(card => card.id === cost)); + assert.ok(semutaIxAllyCostPlayer(game, owner).hand.some(card => card.id === shield.id)); + const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort(); + const spiceOrder: { turn: number; position: number; from: number; card: Game['spiceDeck'][number] }[] = []; + const orderSpice = (faces: string[]) => { + for (let position = 0; position < faces.length; position++) { + const face = faces[position], from = game.spiceDeck.findIndex((card, index) => index >= position && + (face === 'worm' ? 'worm' in card && !card.suppressed && !card.thumper && !card.greatMaker + : 'territory' in card && card.territory === face && !card.discovery)); + assert.ok(from >= position, `Select an original unopened ${face} Spice Card`); + const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card); + spiceOrder.push({ turn: game.turn, position, from, card: structuredClone(card) }); + } + }; + orderSpice(advanced ? ['funeral_plain', 'hagga_basin'] : ['funeral_plain']); + let beforeBid: Game | undefined, bid: SemutaIxAllyCostStep | undefined, bidAt = -1; + for (let count = 0; count < 1000 && !semutaIxAllyCostPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) { + const next = nextSemutaIxAllyCost(game); assert.ok(next); + if (next.actor === holder && next.action.type === 'richeseBid' && next.action.amount === 1) { + beforeBid = structuredClone(game); bid = structuredClone(next); bidAt = actions.length; + } + act(next.actor, next.action); + } + assert.ok(beforeBid && bid && bidAt >= 0); + const acquired = structuredClone(game), acquisition = { before: beforeBid, bid, + steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 }; + assert.equal(semutaIxAllyCostPlayer(beforeBid, holder).spice - semutaIxAllyCostPlayer(game, holder).spice, 1); + reach(state => state.turn === 2 && state.phase === 1 && semutaIxAllyCostClean(state) && + !state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus && state.ready.length === 0); + orderSpice(['worm', 'the_great_flat', ...(advanced ? ['broken_land'] : [])]); + reach(state => state.turn === 2 && state.phase === 1 && state.nexus && !state.spiceWindow && + !state.spiceResolution && semutaIxAllyCostClean(state)); + const alliance = transition(() => { + act(owner, { type: 'alliance', target: buyer }); act(buyer, { type: 'alliance', target: owner }); + }); + assert.equal(semutaIxAllyCostPlayer(game, owner).ally, buyer); + assert.equal(semutaIxAllyCostPlayer(game, buyer).ally, owner); + const beforeBidding = structuredClone(game), biddingAt = actions.length; + reach(state => state.turn === 2 && state.phase === 3 && !!state.auction && + state.auction.index === 0 && state.auction.active === buyer && semutaIxAllyCostClean(state)); + const pool = { before: beforeBidding, steps: structuredClone(actions.slice(biddingAt)), after: structuredClone(game) }; + let paymentBefore: Game | null = null, paymentAt = -1; + const beforePurchase = structuredClone(game), purchaseAt = actions.length; + const awaitingReplacement = (state: Game) => state.decision?.kind === 'ixAllyCard'; + act(buyer, { type: 'bid', amount: 1, allyPayment: 0 }); + for (let count = 0; !awaitingReplacement(game) && count < 100; count++) { + const before = structuredClone(game), at = actions.length; + if (!game.response && !game.phaseOpening && game.decision?.kind === 'auctionPayment') { + assert.equal(game.decision.player, buyer); act(buyer, { type: 'decision', karama: false }); + } else { + const next = nextSemutaIxAllyCost(game); assert.ok(next); act(next.actor, next.action); + } + if (awaitingReplacement(game)) { paymentBefore = before; paymentAt = at; } + } + assert.equal(game.decision?.kind, 'ixAllyCard'); + const purchase = { before: beforePurchase, steps: structuredClone(actions.slice(purchaseAt)), after: structuredClone(game) }; + assert.ok(paymentBefore && paymentAt >= 0); + const payment = { before: paymentBefore, steps: structuredClone(actions.slice(paymentAt)), after: structuredClone(game) }; + assert.equal(semutaIxAllyCostPlayer(payment.before, buyer).spice - semutaIxAllyCostPlayer(game, buyer).spice, 1); + assert.ok(game.pendingIxAlly && game.currentAuctionSale && game.auction); + const deliveredCard = semutaIxAllyCostPlayer(game, buyer).hand.find(card => card.id === game.pendingIxAlly!.card); + assert.ok(deliveredCard); assert.equal(game.currentAuctionSale.origin, 'normal'); + const requestAction: Action = { type: 'decision', accept: true }; + const request = transition(() => { act(buyer, requestAction); }); + assert.ok(game.response?.kind === 'ixAllyCard'); assert.equal(game.response.owner, owner); + assert.equal(game.response.recipient, buyer); assert.equal(game.decision ?? null, null); + assert.ok(game.deck[0]); assert.deepEqual(semutaIxAllyCostInventory(game), originalInventory); + return { game, kind: 'ixAllyCard' as const, advanced, blackMarket: false as const, + owner, holder, spender, buyer, recipient: buyer, richese, cost, karama: cost, semuta: SEMUTA_DRUG_ID, + action: { type: 'card', card: cost, mode: 'cancel' } as Action, + originalResponse: structuredClone(game.response), originalPending: structuredClone(game.pendingIxAlly!), + originalSale: structuredClone(game.currentAuctionSale!), originalAuction: structuredClone(game.auction!), + originalDeck: structuredClone(game.deck), deliveredCard: structuredClone(deliveredCard), deliveredCardId: deliveredCard.id, + replacementTop: structuredClone(game.deck[0]), requestAction, initial, lobby, setup, beforeStartingDeal, + afterSetup, setupChoices, dealOrder, deal: selected.map(card => card.id), originalInventory, originalSpice, spiceOrder, + acquisition, acquired, cache: acquisition, alliance, pact: alliance, pool, purchase, payment, request, + actions, steps: actions, sourceActionCount: actions.length, + staging: [ + 'Original public Basic start or original undealt Advanced initializer for R/C/I/F/G with native CHOAM/Ix decks. Only unopened deal/Spice positions and scalar lotteries are selected. Native Ix chooses its inspected Shield; Guild receives the printed Karama; CHOAM actually pays cache1 for Semuta.', + 'Original first-turn movement is unused. A real turn2 worm over the empty Funeral pile opens Nexus; Ix and Fremen actually propose and accept their pact. Native HMS, pool-return, optional Technology decline and other original controls remain real actions.', + 'Fremen bids and pays one own spice for an actual ordinary auction card. There is no Emperor seat, so the printed payment goes to the Bank. The card is delivered into the buyer hand and native pendingIxAlly/currentAuctionSale remain; Fremen really requests replacement before the cancellation cost.', + 'No played hand, wallet, card, ally, force, clock, pool, purchase, pending control or replacement is installed. Cache replacement and absent-Ix Secret Ally are outside these ordinary native sources.', + ] }; +} +export type SemutaIxAllyCostFixture = ReturnType; diff --git a/tests/fixture-semuta-native-markets.ts b/tests/fixture-semuta-native-markets.ts new file mode 100644 index 00000000..4d0942b5 --- /dev/null +++ b/tests/fixture-semuta-native-markets.ts @@ -0,0 +1,198 @@ +import assert from 'node:assert/strict'; +import { + createGame, initializeSemutaGameForAudit, joinGame, newPlayer, type Action, type Game, +} from '../game/engine'; +import { quoteChoamSaleCancellation } from '../game/choam-sale-cancellation'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus'; +import { + actSemutaRicheseCacheKarama, nextSemutaRicheseCacheKarama, + semutaRicheseCacheKaramaClean, semutaRicheseCacheKaramaInventory, semutaRicheseCacheKaramaPlayer, +} from './fixture-semuta-richese-cache-karama'; +import { passSemutaFremenGuildMovement, claimSemutaFremenGuildMovement } from './fixture-semuta-fremen-guild-movement'; + +export type SemutaNativeMarketsKind = 'choamSale' | 'richeseBlackMarket'; +export type SemutaNativeMarketsStep = { actor: string; action: Action }; +export const actSemutaNativeMarkets = actSemutaRicheseCacheKarama; +export const passSemutaNativeMarkets = passSemutaFremenGuildMovement; +export const claimSemutaNativeMarkets = claimSemutaFremenGuildMovement; +export const semutaNativeMarketsPlayer = semutaRicheseCacheKaramaPlayer; +export const semutaNativeMarketsInventory = semutaRicheseCacheKaramaInventory; +export const semutaNativeMarketsClean = semutaRicheseCacheKaramaClean; + +/** Native no-spending programme; the selected market response/fresh batch stays + * explicit. Original early markets are actually closed before cache acquisition. */ +export function nextSemutaNativeMarkets(game: Game): SemutaNativeMarketsStep | null { + if (game.pendingTreacheryDiscard || game.truthtrance || + ['choamSale', 'richeseBlackMarket'].includes(game.response?.kind ?? '')) return null; + const step = nextSemutaRicheseCacheKarama(game); + if (step?.action.type === 'stormDial' && game.turn === 1) + return { actor: step.actor, action: { ...step.action, amount: 0 } }; + return step; +} +export function advanceSemutaNativeMarkets(game: Game, until: (state: Game) => boolean, + actions: SemutaNativeMarketsStep[] = []): Game { + for (let count = 0; count < 1800; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished'); + const step = nextSemutaNativeMarkets(game); assert.ok(step, 'Choose the actual native market or fresh-discard response'); + game = actSemutaNativeMarkets(game, step.actor, step.action, actions); + } + throw Error('The genuine native-market programme did not reach its finite boundary'); +} +export function allowSemutaNativeMarkets(game: Game, actions: SemutaNativeMarketsStep[] = []): Game { + const kind = game.response?.kind; assert.ok(kind === 'choamSale' || kind === 'richeseBlackMarket'); + for (let count = 0; count < game.players.length && game.response?.kind === kind; count++) { + const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor); + game = actSemutaNativeMarkets(game, actor.id, { type: 'passResponse' }, actions); + } + assert.notEqual(game.response?.kind, kind); return game; +} +/** Finish only a positive sale's actual fresh discard, or the accepted original + * Black Market's real zero bids. Keep the next CHOAM/compulsory-cache chooser. */ +export function finishSemutaNativeMarkets(game: Game, kind: SemutaNativeMarketsKind, + actions: SemutaNativeMarketsStep[] = []): Game { + if (kind === 'choamSale' && game.pendingTreacheryDiscard) { + assert.equal(game.pendingTreacheryDiscard.continuation.kind, 'choamSaleDiscard'); + assert.equal(game.pendingTreacheryDiscard.batch.cause, 'choamSale'); + game = passSemutaNativeMarkets(game, actions); + } + assert.equal(game.pendingTreacheryDiscard ?? null, null, 'Resolve the original cancellation cost first'); + assert.notEqual(game.response?.kind, kind); + if (kind === 'richeseBlackMarket') { + const event = game.richeseAuction?.event; + for (let count = 0; event && game.richeseAuction?.event === event && count < 12; count++) { + const lot = game.richeseAuction; + assert.equal(lot.source, 'blackMarket'); assert.equal(game.response ?? null, null); + assert.equal(game.decision ?? null, null); assert.ok(lot.active); assert.equal(lot.bid, 0); + game = actSemutaNativeMarkets(game, lot.active, { type: 'richeseBid', event, amount: null }, actions); + } + assert.equal(game.richeseAuction ?? null, null); assert.equal(game.decision?.kind, 'richeseDeclaration'); + } else { + assert.equal(game.decision?.kind, 'choamMarket'); assert.equal(game.choamMarket?.sale, undefined); + } + return game; +} + +/** Public Basic/original Advanced R/C/E source. Duplicate sales obtain their + * witness by an actual paid normal auction, not a played hand assignment. */ +export function createSemutaNativeMarketsFixture(options: { + kind: SemutaNativeMarketsKind; advanced?: boolean; worthless?: boolean; +}) { + const kind = options.kind, advanced = options.advanced ?? kind === 'richeseBlackMarket'; + const worthless = options.worthless ?? false; + assert.ok(kind !== 'richeseBlackMarket' || advanced && !worthless); + const richese = 'richese', holder = 'choam', spender = 'emperor', owner = kind === 'choamSale' ? holder : richese; + let game = createGame('SEMUTANATIVEMARKETS', newPlayer(richese, 'Richese', 'richese'), advanced, ['choam']); + for (const faction of [holder, spender] as const) joinGame(game, newPlayer(faction, faction, faction)); + const initial = structuredClone(game), actions: SemutaNativeMarketsStep[] = []; + const act = (actor: string, action: Action) => { game = actSemutaNativeMarkets(game, actor, action, actions); }; + const reach = (until: (state: Game) => boolean) => { game = advanceSemutaNativeMarkets(game, until, actions); }; + const transition = (run: () => void) => { + const before = structuredClone(game), start = actions.length; run(); + return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) }; + }; + for (const player of game.players) act(player.id, { type: 'ready' }); + const lobby = structuredClone(game); + if (advanced) game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game)); + else act(game.host, { type: 'start' }); + const setup = structuredClone(game), initialization = { before: lobby, after: setup, + entry: advanced ? 'original-undealt-semuta-preview' : 'public-basic-start' }; + assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced); assert.equal(game.semutaPreview, true); + assert.ok(game.players.every(player => player.hand.length === 0)); + assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens && + !game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery); + const originalInventory = semutaNativeMarketsInventory(game); assert.equal(originalInventory.length, 45); + assert.equal(game.deck.length, 35); assert.equal(game.richeseCache!.length, 10); + const printed = game.deck.find(card => card.effect === 'karama'); assert.ok(printed); + const duplicate = game.deck.find(card => card.kind === 'shield' && !card.effect && + game.deck.some(other => other.id !== card.id && other.name === card.name)); assert.ok(duplicate); + const twin = game.deck.find(card => card.id !== duplicate.id && card.name === duplicate.name); assert.ok(twin); + const filler = game.deck.find(card => card.kind === 'projectile' && !card.effect); assert.ok(filler); + const junk = worthless ? game.deck.find(card => card.kind === 'worthless') : null; + if (worthless) assert.ok(junk); + const saleCard = kind === 'choamSale' ? worthless ? junk!.id : duplicate.id : duplicate.id; + const witness = kind === 'choamSale' && !worthless ? twin.id : undefined; + const selected = kind === 'richeseBlackMarket' ? [duplicate.id, filler.id, printed.id] + : [filler.id, saleCard, printed.id, ...(witness ? [witness] : [])]; + const dealOrder = selected.map((card, position) => { + const from = game.deck.findIndex(candidate => candidate.id === card); assert.ok(from >= position); + [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; + return { card, from, position }; + }); + const beforeStartingDeal = structuredClone(game), cost = printed.id; + reach(state => state.status === 'playing'); const afterSetup = structuredClone(game); + assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.slice(0, 3)); + const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort(); + const spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = []; + for (let position = 0; position < (advanced ? kind === 'richeseBlackMarket' ? 4 : 2 : 1); position++) { + const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery && + ['hagga_basin', 'rock_outcroppings', 'broken_land', 'oh_gap'].includes(card.territory)); + assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card); + spiceOrder.push({ position, from, card: structuredClone(card) }); + } + assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice); + let beforeBid: Game | undefined, bid: SemutaNativeMarketsStep | undefined, bidAt = -1; + for (let count = 0; count < 900 && !semutaNativeMarketsPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) { + const step = nextSemutaNativeMarkets(game); assert.ok(step); + if (step.actor === holder && step.action.type === 'richeseBid' && step.action.amount === 1) { + beforeBid = structuredClone(game); bid = structuredClone(step); bidAt = actions.length; + } + act(step.actor, step.action); + } + assert.ok(beforeBid && bid && bidAt >= 0); + const acquired = structuredClone(game), acquisition = { before: beforeBid, bid, + steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 }; + assert.equal(semutaNativeMarketsPlayer(beforeBid, holder).spice - semutaNativeMarketsPlayer(game, holder).spice, 1); + assert.equal(semutaNativeMarketsPlayer(game, richese).spice - semutaNativeMarketsPlayer(beforeBid, richese).spice, 1); + let normalPurchase: ReturnType | null = null; + if (witness) { + reach(state => state.phase === 3 && semutaNativeMarketsClean(state) && !!state.auction && + state.auction.index === 0 && state.auction.active === holder && state.auction.bid === 0); + assert.equal(game.auction!.cards[0].id, witness); + normalPurchase = transition(() => { + act(holder, { type: 'bid', amount: 1, allyPayment: 0 }); + reach(state => semutaNativeMarketsClean(state) && semutaNativeMarketsPlayer(state, holder).hand.some(card => card.id === witness)); + }); + assert.equal(semutaNativeMarketsPlayer(normalPurchase.before, holder).spice - semutaNativeMarketsPlayer(game, holder).spice, 1); + assert.equal(semutaNativeMarketsPlayer(game, holder).hand.filter(card => card.id === witness).length, 1); + } + let nativeAction: Action; + if (kind === 'choamSale') { + reach(state => state.turn === 1 && state.phase === 3 && !state.response && !state.phaseOpening && state.decision?.kind === 'choamMarket'); + nativeAction = { type: 'decision', mode: 'sell', card: saleCard, ...(witness ? { witness } : {}) }; + } else { + reach(state => state.turn === 2 && state.phase === 3 && !state.response && !state.phaseOpening && state.decision?.kind === 'richeseBlackMarket'); + nativeAction = { type: 'decision', event: game.richeseBidding!.event, card: saleCard, + method: 'normal', direction: 'clockwise' }; + } + const beforeDeclaration = structuredClone(game), declaration = transition(() => { act(owner, nativeAction); }); + assert.equal(game.response?.kind, kind); assert.equal(game.response?.owner, owner); + assert.equal(game.decision ?? null, null); assert.equal(game.phaseOpening ?? null, null); + assert.equal(game.pendingTreacheryDiscard ?? null, null); + assert.deepEqual(semutaNativeMarketsPlayer(game, owner), semutaNativeMarketsPlayer(beforeDeclaration, owner)); + assert.ok(semutaNativeMarketsPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID)); + assert.ok(semutaNativeMarketsPlayer(game, spender).hand.some(card => card.id === cost)); + const source = structuredClone(game), originalResponse = structuredClone(game.response!); + const originalMarket = structuredClone(game.choamMarket ?? null), originalLot = structuredClone(game.richeseAuction ?? null); + const originalRound = structuredClone(game.richeseBidding ?? null), originalAuction = structuredClone(game.auction ?? null); + const originalDeck = game.deck.map(card => card.id), originalCache = game.richeseCache!.map(card => card.id); + const sale = structuredClone(game.choamMarket?.sale ?? null), price = kind === 'choamSale' ? worthless ? 2 : 3 : 0; + const expected = kind === 'choamSale' ? quoteChoamSaleCancellation(game, game.response!) : null; + const action: Action = { type: 'card', card: cost, mode: 'cancel' }; + assert.deepEqual(semutaNativeMarketsInventory(game), originalInventory); + return { game, source, beforeCost: source, kind, advanced, worthless, richese, holder, spender, owner, + cost, semuta: SEMUTA_DRUG_ID, action, nativeAction, originalResponse, + saleCard, witness, price, sale, originalMarket, originalLot, originalRound, originalAuction, originalDeck, originalCache, expected, + initial, lobby, setup, initialization, beforeStartingDeal, afterSetup, selected, dealOrder, originalSpice, spiceOrder, + acquired, acquisition, normalPurchase, beforeDeclaration, declaration, + originalInventory, actions, sourceActions: actions, sourceActionCount: actions.length, + staging: [ + 'Public Basic start or original Advanced Semuta initializer on a genuinely ready undealt R/C/E lobby, exact CHOAM and no optional modules. Only unopened original starting/next-normal-card/Spice positions and scalar lotteries are selected; original main35/cache10 remain physical. Emperor receives printed Karama and CHOAM buys original cache Semuta for1.', + kind === 'richeseBlackMarket' ? 'Original first-turn phases finish through actual no-spending controls. At the genuine turn2 Advanced Black Market, Richese offers its actually dealt Shield using normal bidding. Stop at the original response with card still held, no bids/payment, and compulsory-cache accounting unresolved.' : + witness ? 'CHOAM holds its actually dealt Shield. Its first real normal bid1 buys a second exact printed-name physical copy after cache Semuta. At the original closing Bidding market it declares the first card for3 with that genuinely acquired witness; neither card has moved or produced revenue.' : + 'CHOAM retains its actually dealt Worthless through earlier native markets. After real cache Semuta and original normal Bidding, it declares that original Worthless for2 at the closing market, with no witness or pre-applied discard/payout.', + 'Stop before the separate original printed cancellation cost. Denial preserves original held sale/Black Market card and revenue0, then resumes the next native market/declaration. Positive CHOAM sale honors its existing actual fresh-discard window; accepted Black Market finishes only real zero bids, not a fabricated outcome or purchase.', + ] }; +} +export type SemutaNativeMarketsFixture = ReturnType; diff --git a/tests/semuta-fremen-grant-cost-engine.test.ts b/tests/semuta-fremen-grant-cost-engine.test.ts new file mode 100644 index 00000000..2036878c --- /dev/null +++ b/tests/semuta-fremen-grant-cost-engine.test.ts @@ -0,0 +1,167 @@ +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 { quoteFremenRevivalGrant, validateFremenRevivalGrant } from '../game/fremen-revival-grant'; +import { + createSemutaFremenGrantCostFixture as fixture, actSemutaFremenGrantCost as act, + passSemutaFremenGrantCost as pass, claimSemutaFremenGrantCost as claim, + allowSemutaFremenGrantCost as allow, finishSemutaFremenGrantCost as finish, + semutaFremenGrantCostPlayer as player, semutaFremenGrantCostInventory as inventory, + semutaFremenGrantCostPhysical as physical, type SemutaFremenGrantCostFixture, +} from './fixture-semuta-fremen-grant-cost'; + +const clock = (game: Game) => ({ turn: game.turn, phase: game.phase, active: game.active, ready: game.ready, + order: game.order, movementRemaining: game.movementRemaining, storm: game.storm, + spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard }); +function reject(game: Game, actor: string, action: Action) { + const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before); +} +function nativeSource(f: SemutaFremenGrantCostFixture) { + assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, f.advanced); assert.equal(f.setup.semutaPreview, true); + assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0)); + assert.deepEqual(f.afterSetup.players.flatMap(p => p.hand.map(card => card.id)), f.deal); + assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama')); + assert.equal(player(f.afterSetup, f.owner).forces['sietch_tabr:14'], 10); + assert.equal(f.acquisition.bid.actor, f.holder); assert.equal(f.acquisition.bid.action.amount, 1); + assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquired, f.holder).spice, 1); + assert.equal(player(f.acquired, f.seller).spice - player(f.acquisition.before, f.seller).spice, 1); + assert.equal(f.shipment.before.storm, 12); + assert.equal(player(f.shipment.before, f.recipient).spice - player(f.shipment.after, f.recipient).spice, 6); + assert.ok(f.shipment.steps.some(step => step.actor === f.recipient && step.action.type === 'ship' && step.action.amount === 3 && step.action.elite === 0)); + assert.equal(player(f.shipment.after, f.recipient).forces[f.location], 3); + assert.equal(f.storm.before.storm, 12); assert.ok(f.storm.after.storm >= 13 && f.storm.after.storm <= 18); + assert.equal(player(f.storm.before, f.recipient).tanks, 0); assert.equal(player(f.storm.after, f.recipient).tanks, 3); + assert.equal(player(f.storm.after, f.recipient).forces[f.location] ?? 0, 0); + assert.equal(f.alliance.steps.length, 2); assert.ok(f.alliance.steps.every(step => step.action.type === 'alliance')); + assert.equal(f.alliance.before.nexus, true); assert.equal(player(f.game, f.owner).ally, f.recipient); + assert.equal(player(f.game, f.recipient).ally, f.owner); + assert.equal(f.intrinsicBefore.freeRemaining, 1); assert.equal(f.intrinsicBefore.forcesRemaining, 3); + assert.equal(f.priorRevival.steps[0].actor, f.recipient); assert.deepEqual(f.priorRevival.steps[0].action, f.priorRevivalAction); + assert.equal(player(f.priorRevival.before, f.recipient).spice, player(f.priorRevival.after, f.recipient).spice); + assert.equal(player(f.priorRevival.after, f.recipient).revived, 1); + assert.equal(player(f.priorRevival.after, f.recipient).freeForcesRevived, 1); + assert.equal(player(f.game, f.recipient).tanks, 2); assert.equal(player(f.game, f.recipient).reserves, 18); + assert.equal(player(f.game, f.recipient).spice, 4); assert.equal(player(f.game, f.recipient).revived, 1); + assert.equal(player(f.game, f.recipient).freeForcesRevived, 1); + assert.equal(f.intrinsic.freeRemaining, 0); assert.equal(f.intrinsic.forcesRemaining, 2); + assert.equal(f.game.phase, 4); assert.equal(f.game.turn, 2); assert.equal(f.game.pendingRevival ?? null, null); + assert.equal(f.game.decision ?? null, null); assert.equal(f.game.phaseOpening ?? null, null); + assert.equal(f.game.response?.kind, 'fremenRevivalGrant'); assert.deepEqual(f.game.response, f.originalResponse); + assert.deepEqual(f.fremenGrant, { turn: 2, owner: f.owner, recipient: f.recipient }); + assert.deepEqual(quoteFremenRevivalGrant(f.game, f.owner), f.fremenGrant); validateFremenRevivalGrant(f.game, f.fremenGrant); + assert.deepEqual(f.game.revivalRules, f.originalRules); assert.deepEqual(f.game.freeRevival, f.originalFreeRevival); + assert.deepEqual(physical(f.game), physical(f.beforeDeclaration)); + assert.ok(player(f.game, f.holder).hand.some(card => card.id === f.semuta)); + assert.ok(player(f.game, f.spender).hand.some(card => card.id === f.cost)); + assert.equal(f.originalInventory.length, 45); assert.deepEqual(inventory(f.game), f.originalInventory); + assert.equal(f.sourceActionCount, f.actions.length); +} +function open(f: SemutaFremenGrantCostFixture) { + const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard; + assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaFremenGrantDiscard'); + if (pending.continuation.kind !== 'karamaFremenGrantDiscard') throw Error('Expected the original allied grant cost'); + assert.equal(pending.batch.cause, 'karama:fremenRevivalGrant'); assert.equal(pending.reaction?.stage, 'offer'); + assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, face: entry.publicFace })), + [{ card: f.cost, by: f.spender, face: true }]); + assert.deepEqual(pending.continuation.use.response, f.originalResponse); + assert.deepEqual(pending.continuation.resume.response, f.originalResponse); + assert.equal(game.response ?? null, null); assert.equal(game.pendingRevival ?? null, null); + assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(clock(game), clock(f.game)); + assert.deepEqual(game.revivalRules, f.originalRules); assert.deepEqual(game.freeRevival, f.originalFreeRevival); + assert.equal(game.discard.filter(card => card.id === f.cost).length, 1); + assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true); + assert.deepEqual(inventory(game), f.originalInventory); return game; +} +function nativeOutcome(state: Game, f: SemutaFremenGrantCostFixture, canceled: boolean) { + const game = finish(state); + assert.equal(game.response ?? null, null); assert.equal(game.pendingRevival ?? null, null); + assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.decision ?? null, null); + assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(clock(game), clock(f.game)); + if (canceled) { + assert.deepEqual(game.freeRevival, f.originalFreeRevival); + assert.deepEqual(game.revivalRules, { ...f.originalRules, + fremenGrantBlocked: [...(f.originalRules?.fremenGrantBlocked ?? []), f.recipient] }); + } else { + assert.deepEqual(game.freeRevival, [...f.originalFreeRevival, f.recipient]); + assert.deepEqual(game.revivalRules, f.originalRules); + } + const current = viewGame(game, f.recipient).revival; + assert.equal(current.freeRemaining, canceled ? 0 : 2); assert.equal(current.forcesRemaining, 2); + assert.equal(current.limit, f.intrinsic.limit); + reject(game, f.owner, f.nativeAction); + const beforeReturn = structuredClone(game), returned = finish(act(structuredClone(game), f.recipient, f.replacementAction)); + assert.equal(returned.response ?? null, null); assert.equal(returned.pendingRevival ?? null, null); + assert.equal(returned.phase, 4); assert.equal(returned.turn, 2); + assert.equal(player(returned, f.recipient).reserves, 20); assert.equal(player(returned, f.recipient).tanks, 0); + assert.equal(player(returned, f.recipient).revived, 3); + assert.equal(player(returned, f.recipient).freeForcesRevived, canceled ? 1 : 3); + assert.equal(player(returned, f.recipient).spice, player(beforeReturn, f.recipient).spice - (canceled ? f.deniedCost : f.allowedCost)); + assert.deepEqual(player(returned, f.recipient).forces, player(f.game, f.recipient).forces); + assert.equal(player(returned, f.recipient).elites?.reserves, player(f.game, f.recipient).elites?.reserves); + assert.equal(player(returned, f.recipient).elites?.tanks ?? 0, player(f.game, f.recipient).elites?.tanks ?? 0); + for (const p of game.players.filter(p => p.id !== f.recipient)) { + assert.equal(player(returned, p.id).spice, p.spice); assert.equal(player(returned, p.id).reserves, p.reserves); + assert.equal(player(returned, p.id).tanks, p.tanks); assert.deepEqual(player(returned, p.id).forces, p.forces); + } + assert.deepEqual(returned.revivalRules, game.revivalRules); assert.deepEqual(returned.freeRevival, game.freeRevival); + assert.equal(viewGame(returned, f.recipient).revival.forcesRemaining, 0); + reject(returned, f.recipient, f.replacementAction); reject(returned, f.owner, f.nativeAction); + assert.deepEqual(inventory(returned), f.originalInventory); return game; +} +function denied(state: Game, f: SemutaFremenGrantCostFixture, claimed: boolean) { + const game = nativeOutcome(state, f, true); + assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false); + assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed)); + assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed)); + assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed)); + assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed)); + reject(game, f.spender, f.action); return game; +} + +for (const advanced of [false, true]) for (const claimed of [false, true]) void test( + `${advanced ? 'Advanced' : 'Basic'} native Fremen grant cost Semuta ${claimed ? 'claim' : 'pass'} preserves prior free1 and leaves ordinary paid2 legal`, () => { + const f = fixture({ advanced }); nativeSource(f); const offered = open(f); + denied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed); + }, +); + +void test('native Fremen grant allowance retains prior usage and makes only the two remaining ordinary returns free', () => { + for (const advanced of [false, true]) { + const f = fixture({ advanced }); nativeSource(f); const game = nativeOutcome(allow(structuredClone(f.game)), f, false); + assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost)); + assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta)); + } +}); + +void test('native Fremen grant cost rejects stale foreign and underlying grant or revival replies before permission changes', () => { + for (const advanced of [false, true]) { + const f = fixture({ advanced }), game = open(f), event = game.pendingTreacheryDiscard!.batch.event; + reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` }); + reject(game, f.spender, { type: 'semutaCommit', event }); reject(game, f.spender, f.action); + reject(game, f.owner, f.nativeAction); reject(game, f.recipient, f.replacementAction); + reject(game, f.owner, { type: 'passResponse' }); reject(game, f.owner, { type: 'ready' }); + const done = denied(pass(game), f, false); reject(done, f.holder, { type: 'semutaCommit', event }); + } +}); + +void test('all four minimal profiles legally claim or pass native Fremen allied revival-grant costs', () => { + for (const advanced of [false, true]) { + const f = fixture({ advanced }), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event; + for (const difficulty of DIFFICULTIES) { + const own = viewGame(offered, f.holder); own.players.find(p => p.id === f.holder)!.bot = difficulty; + const proposed = botActions(own)[0]; assert.ok(proposed, difficulty); + assert.ok(proposed.type === 'semutaPass' || proposed.type === 'semutaCommit'); assert.equal(proposed.event, event); + let game = act(structuredClone(offered), f.holder, proposed); + const claimed = proposed.type === 'semutaCommit'; + if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') { + const selecting = viewGame(game, f.holder); selecting.players.find(p => p.id === f.holder)!.bot = difficulty; + const selected = botActions(selecting)[0]; assert.ok(selected); + assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost); + game = act(game, f.holder, selected); + } else if (game.pendingTreacheryDiscard) game = pass(game); + denied(game, f, claimed); + } + } +}); diff --git a/tests/semuta-ix-ally-cost-engine.test.ts b/tests/semuta-ix-ally-cost-engine.test.ts new file mode 100644 index 00000000..eea12e85 --- /dev/null +++ b/tests/semuta-ix-ally-cost-engine.test.ts @@ -0,0 +1,169 @@ +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 { + createSemutaIxAllyCostFixture as fixture, actSemutaIxAllyCost as act, + passSemutaIxAllyCost as pass, claimSemutaIxAllyCost as claim, + allowSemutaIxAllyCost as allow, finishSemutaIxAllyCost as finish, + semutaIxAllyCostPlayer as player, semutaIxAllyCostInventory as inventory, + type SemutaIxAllyCostFixture, +} from './fixture-semuta-ix-ally-cost'; + +const variants = [{ advanced: false }, { advanced: true }]; +const physical = (game: Game) => ({ + turn: game.turn, phase: game.phase, storm: game.storm, order: game.order, + spice: game.spice, spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard, + mobile: game.mobileStronghold, aid: game.aid, offers: game.allianceOffers, + players: game.players.map(p => ({ id: p.id, spice: p.spice, forces: p.forces, reserves: p.reserves, + tanks: p.tanks, elites: p.elites, leaders: p.leaders, ally: p.ally, allySinceTurn: p.allySinceTurn, + moved: p.moved, shipped: p.shipped })), +}); +function reject(game: Game, actor: string, action: Action) { + const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before); +} +function nativeSource(f: SemutaIxAllyCostFixture) { + assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, f.advanced); assert.equal(f.setup.semutaPreview, true); + assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0)); + assert.deepEqual(f.game.players.map(p => p.faction), ['richese', 'choam', 'ixians', 'fremen', 'guild']); + assert.deepEqual(f.afterSetup.players.flatMap(p => p.hand.map(card => card.id)), f.deal); + assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama')); + assert.ok(f.setupChoices.steps.some(step => step.actor === f.owner && step.action.type === 'decision' && + player(f.afterSetup, f.owner).hand.some(card => card.id === step.action.card && card.kind === 'shield'))); + assert.equal(player(f.afterSetup, f.buyer).forces['sietch_tabr:14'], 10); + assert.equal(f.acquisition.bid.actor, f.holder); assert.equal(f.acquisition.bid.action.amount, 1); + assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquisition.after, f.holder).spice, 1); + assert.equal(player(f.acquisition.after, f.richese).spice - player(f.acquisition.before, f.richese).spice, 1); + assert.equal(f.pact.before.turn, 2); assert.equal(f.pact.before.phase, 1); assert.equal(f.pact.before.nexus, true); + assert.equal(f.pact.steps.length, 2); assert.ok(f.pact.steps.every(step => step.action.type === 'alliance')); + assert.equal(player(f.game, f.owner).ally, f.buyer); assert.equal(player(f.game, f.buyer).ally, f.owner); + assert.ok(f.spiceOrder.some(row => row.turn === 2 && 'worm' in row.card)); + assert.ok(f.pool.steps.some(step => step.actor === f.owner && step.action.type === 'decision' && step.action.position === 'bottom'), + 'The actual native Ixian pool return precedes the ordinary auction'); + assert.ok(f.purchase.steps.some(step => step.actor === f.buyer && step.action.type === 'bid' && step.action.amount === 1)); + assert.equal(player(f.payment.before, f.buyer).spice - player(f.payment.after, f.buyer).spice, 1); + assert.ok(f.payment.steps.length > 0); + assert.equal(f.originalSale.origin, 'normal'); assert.equal(f.originalSale.winner, f.buyer); + assert.equal(f.originalSale.amount, 1); assert.equal(f.originalSale.free, false); + assert.equal(f.originalPending.player, f.buyer); assert.equal(f.originalPending.card, f.deliveredCard.id); + assert.equal(f.originalPending.free, false); + assert.equal(player(f.payment.before, f.buyer).hand.some(card => card.id === f.deliveredCard.id), false); + assert.equal(player(f.payment.after, f.buyer).hand.filter(card => card.id === f.deliveredCard.id).length, 1); + assert.deepEqual(f.request.steps, [{ actor: f.buyer, action: f.requestAction }]); + assert.equal(f.game.response?.kind, 'ixAllyCard'); assert.equal(f.game.response?.owner, f.owner); + assert.equal(f.game.response?.recipient, f.buyer); assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 3); + assert.equal(f.game.decision ?? null, null); assert.equal(f.game.pendingTreacheryDiscard ?? null, null); + assert.deepEqual(f.game.pendingIxAlly, f.originalPending); assert.deepEqual(f.game.currentAuctionSale, f.originalSale); + assert.deepEqual(f.game.deck, f.originalDeck); assert.equal(f.originalInventory.length, 57); + assert.deepEqual(inventory(f.game), f.originalInventory); assert.equal(f.sourceActionCount, f.actions.length); +} +function open(f: SemutaIxAllyCostFixture) { + const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard; + assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaMarketDiscard'); + if (pending.continuation.kind !== 'karamaMarketDiscard') throw Error('Use the original paid ally replacement cost'); + assert.equal(pending.batch.cause, 'karama:ixAllyCard'); assert.equal(pending.reaction?.stage, 'offer'); + assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, face: entry.publicFace })), + [{ card: f.cost, by: f.spender, face: true }]); + assert.deepEqual(pending.continuation.use.response, f.originalResponse); + assert.deepEqual(pending.continuation.resume.response, f.originalResponse); + assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null); + assert.deepEqual(game.pendingIxAlly, f.originalPending); assert.deepEqual(game.currentAuctionSale, f.originalSale); + assert.deepEqual(game.auction, f.originalAuction); assert.deepEqual(game.deck, f.originalDeck); + assert.equal(player(game, f.buyer).hand.filter(card => card.id === f.deliveredCard.id).length, 1); + assert.deepEqual(physical(game), physical(f.game)); + assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false); + assert.equal(game.discard.filter(card => card.id === f.cost).length, 1); + assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true); + assert.deepEqual(inventory(game), f.originalInventory); return game; +} +function canceled(state: Game, f: SemutaIxAllyCostFixture, claimed: boolean) { + assert.equal(state.pendingTreacheryDiscard ?? null, null); assert.equal(state.pendingIxAlly ?? null, null); + const steps: { actor: string; action: Action }[] = [], game = finish(state, steps); + assert.equal(game.currentAuctionSale ?? null, null); assert.equal(game.response ?? null, null); + assert.equal(game.pendingIxAlly ?? null, null); assert.equal(game.pendingTreacheryDiscard ?? null, null); + assert.equal(game.auction?.index, f.originalAuction.index + 1); + assert.deepEqual(game.deck, f.originalDeck, 'Prevention never draws a replacement or replays the auction pool'); + assert.equal(player(game, f.buyer).hand.filter(card => card.id === f.deliveredCard.id).length, 1); + assert.equal(game.discard.some(card => card.id === f.deliveredCard.id), false); + assert.deepEqual(player(game, f.buyer).hand, player(f.game, f.buyer).hand); + assert.deepEqual(physical(game), physical(f.game), 'The original bank payment and physical board stay committed'); + assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed)); + assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed)); + assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed)); + assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed)); + assert.ok(steps.every(step => !['bid', 'richeseBid', 'ship', 'move', 'card'].includes(step.action.type))); + reject(game, f.buyer, f.requestAction); reject(game, f.spender, f.action); + assert.deepEqual(inventory(game), f.originalInventory); return game; +} + +for (const variant of variants) for (const claimed of [false, true]) void test( + `${variant.advanced ? 'Advanced' : 'Basic'} native Ixian ally paid ordinary replacement cost Semuta ${claimed ? 'claim' : 'pass'} keeps the delivered card and finishes the paid sale once`, () => { + const f = fixture(variant); nativeSource(f); const offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event; + const game = canceled(claimed ? claim(offered, f.holder) : pass(offered), f, claimed); + reject(game, f.holder, { type: 'semutaCommit', event }); reject(game, f.holder, { type: 'semutaPass', event }); + }, +); + +void test('native Ixian ally allowance actually discards the delivered card then draws one replacement after its real fresh-discard passes', () => { + for (const variant of variants) { + const f = fixture(variant); nativeSource(f); let game = allow(structuredClone(f.game)); + assert.equal(game.pendingIxAlly ?? null, null); + assert.equal(player(game, f.buyer).hand.some(card => card.id === f.deliveredCard.id), false); + assert.equal(game.discard.filter(card => card.id === f.deliveredCard.id).length, 1); + assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'ixAllyCard'); + assert.equal(game.pendingTreacheryDiscard?.batch.cause, 'ixAllyCard'); + assert.deepEqual(game.currentAuctionSale, f.originalSale); assert.deepEqual(game.deck, f.originalDeck); + assert.deepEqual(physical(game), physical(f.game)); + const event = game.pendingTreacheryDiscard!.batch.event, steps: { actor: string; action: Action }[] = []; + game = finish(game, steps); + assert.ok(steps.some(step => step.action.type === 'semutaPass' && step.action.event === event)); + assert.ok(steps.every(step => !['bid', 'richeseBid', 'card', 'ship', 'move'].includes(step.action.type))); + assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.currentAuctionSale ?? null, null); + assert.equal(game.auction?.index, f.originalAuction.index + 1); + assert.equal(player(game, f.buyer).hand.filter(card => card.id === f.replacementTop.id).length, 1); + assert.equal(player(game, f.buyer).hand.some(card => card.id === f.deliveredCard.id), false); + assert.equal(game.discard.filter(card => card.id === f.deliveredCard.id).length, 1); + assert.deepEqual(game.deck, f.originalDeck.slice(1)); assert.deepEqual(physical(game), physical(f.game)); + assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost)); + assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta)); + assert.deepEqual(inventory(game), f.originalInventory); + reject(game, f.buyer, f.requestAction); reject(game, f.holder, { type: 'semutaCommit', event }); + } +}); + +void test('fresh Ixian ally costs reject stale and underlying purchase answers while the paid original and pending replacement stay parked', () => { + for (const variant of variants) { + const f = fixture(variant), game = open(f), event = game.pendingTreacheryDiscard!.batch.event; + reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` }); + reject(game, f.spender, { type: 'semutaCommit', event }); reject(game, f.spender, f.action); + reject(game, f.holder, { type: 'semutaSelect', event, card: f.deliveredCard.id }); + reject(game, f.owner, { type: 'passResponse' }); reject(game, f.buyer, f.requestAction); + reject(game, f.buyer, { type: 'decision', karama: false }); + reject(game, f.buyer, { type: 'bid', amount: 2 }); reject(game, f.buyer, { type: 'passBid' }); + assert.deepEqual(game.currentAuctionSale, f.originalSale); assert.deepEqual(game.pendingIxAlly, f.originalPending); + assert.deepEqual(game.deck, f.originalDeck); assert.deepEqual(physical(game), physical(f.game)); + canceled(pass(game), f, false); + } +}); + +void test('all four minimal profiles legally answer native Basic and Advanced Ixian ally replacement costs', () => { + for (const variant of variants) { + const f = fixture(variant), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event; + for (const difficulty of DIFFICULTIES) { + const own = viewGame(offered, f.holder); own.players.find(p => p.id === f.holder)!.bot = difficulty; + const before = structuredClone(own), action = botActions(own)[0]; assert.ok(action, difficulty); + assert.deepEqual(own, before); assert.ok(action.type === 'semutaPass' || action.type === 'semutaCommit'); + assert.equal(action.event, event); + let game = act(structuredClone(offered), f.holder, action); + const claimed = action.type === 'semutaCommit'; + if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') { + const selecting = viewGame(game, f.holder); selecting.players.find(p => p.id === f.holder)!.bot = difficulty; + const selected = botActions(selecting)[0]; assert.ok(selected); + assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost); + game = act(game, f.holder, selected); + } else if (game.pendingTreacheryDiscard) game = pass(game); + canceled(game, f, claimed); + } + } +}); diff --git a/tests/semuta-native-markets-engine.test.ts b/tests/semuta-native-markets-engine.test.ts new file mode 100644 index 00000000..429eb3c5 --- /dev/null +++ b/tests/semuta-native-markets-engine.test.ts @@ -0,0 +1,215 @@ +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 { + createSemutaNativeMarketsFixture as fixture, actSemutaNativeMarkets as act, + passSemutaNativeMarkets as pass, claimSemutaNativeMarkets as claim, + allowSemutaNativeMarkets as allow, finishSemutaNativeMarkets as finish, + semutaNativeMarketsPlayer as player, semutaNativeMarketsInventory as inventory, + type SemutaNativeMarketsFixture, +} from './fixture-semuta-native-markets'; + +const variants = [ + { kind: 'choamSale' as const, advanced: false }, + { kind: 'choamSale' as const, advanced: true }, + { kind: 'choamSale' as const, advanced: false, worthless: true }, + { kind: 'richeseBlackMarket' as const, advanced: true }, +]; +const resources = (game: Game) => ({ turn: game.turn, phase: game.phase, storm: game.storm, + order: game.order, ready: game.ready, movementRemaining: game.movementRemaining, + spice: game.spice, spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard, + players: game.players.map(player => ({ id: player.id, spice: player.spice, bribes: player.bribes, + forces: player.forces, reserves: player.reserves, tanks: player.tanks, elites: player.elites, + leaders: player.leaders, ally: player.ally, shipped: player.shipped, moved: player.moved, revived: player.revived })) }); +function reject(game: Game, actor: string, action: Action) { + const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before); +} +function nativeSource(f: SemutaNativeMarketsFixture) { + assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, f.advanced); assert.equal(f.setup.semutaPreview, true); + assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0)); + assert.deepEqual(f.game.players.map(player => player.faction), ['richese', 'choam', 'emperor']); + assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama')); + assert.ok(player(f.afterSetup, f.owner).hand.some(card => card.id === f.saleCard)); + assert.notEqual(f.saleCard, f.semuta); assert.notEqual(f.saleCard, f.cost); + assert.equal(f.acquisition.price, 1); + assert.ok(f.acquisition.steps.some(step => step.actor === f.holder && step.action.type === 'richeseBid' && step.action.amount === 1)); + assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquisition.after, f.holder).spice, 1); + assert.equal(player(f.acquisition.after, f.richese).spice - player(f.acquisition.before, f.richese).spice, 1); + assert.equal(f.game.phase, 3); assert.equal(f.game.response?.kind, f.kind); assert.equal(f.game.response?.owner, f.owner); + assert.equal(f.game.decision ?? null, null); assert.equal(f.game.phaseOpening ?? null, null); + assert.equal(f.game.pendingTreacheryDiscard ?? null, null); + assert.deepEqual(player(f.game, f.owner), player(f.beforeDeclaration, f.owner)); + assert.equal(player(f.game, f.owner).hand.filter(card => card.id === f.saleCard).length, 1); + assert.equal(f.game.discard.some(card => card.id === f.saleCard), false); + assert.deepEqual(f.declaration.steps, [{ actor: f.owner, action: f.nativeAction }]); + if (f.kind === 'choamSale') { + assert.equal(f.game.turn, 1); assert.ok(f.originalMarket && f.sale && f.expected); + assert.equal(f.originalMarket.owner, f.holder); assert.equal(f.originalMarket.resume, 'phase'); + assert.deepEqual(f.sale, { card: f.saleCard, witness: f.witness, price: f.price }); + assert.equal(f.price, f.worthless ? 2 : 3); + if (f.witness) { + assert.ok(f.normalPurchase); + assert.ok(f.normalPurchase.steps.some(step => step.actor === f.holder && step.action.type === 'bid' && step.action.amount === 1)); + assert.equal(player(f.normalPurchase.before, f.holder).spice - player(f.normalPurchase.after, f.holder).spice, 1); + const sold = player(f.game, f.owner).hand.find(card => card.id === f.saleCard); + const witness = player(f.game, f.owner).hand.find(card => card.id === f.witness); + assert.ok(sold && witness); assert.notEqual(sold.id, witness.id); assert.equal(sold.name, witness.name); + } else { + assert.equal(f.normalPurchase, null); + assert.equal(player(f.game, f.owner).hand.find(card => card.id === f.saleCard)?.kind, 'worthless'); + } + } else { + assert.equal(f.game.turn, 2); assert.equal(f.game.advanced, true); assert.ok(f.originalLot && f.originalRound); + assert.equal(f.originalLot.source, 'blackMarket'); assert.equal(f.originalLot.cardId, f.saleCard); + assert.equal(f.originalLot.bid, 0); assert.equal(f.originalLot.bidder, null); + assert.equal(f.originalLot.outcome ?? null, null); assert.equal(f.originalRound.stage, 'lot'); + assert.equal(f.originalLot.method, 'normal'); assert.equal(f.originalCache.length, 9); + assert.equal(f.game.currentAuctionSale ?? null, null); + } + assert.ok(player(f.game, f.holder).hand.some(card => card.id === f.semuta)); + assert.ok(player(f.game, f.spender).hand.some(card => card.id === f.cost)); + assert.equal(f.sourceActionCount, f.actions.length); assert.equal(f.originalInventory.length, 45); + assert.deepEqual(inventory(f.game), f.originalInventory); +} +function open(f: SemutaNativeMarketsFixture) { + const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard; + assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaMarketDiscard'); + assert.equal(pending.reaction?.stage, 'offer'); assert.equal(pending.batch.cause, `karama:${f.kind}`); + assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, face: entry.publicFace })), + [{ card: f.cost, by: f.spender, face: true }]); + assert.notEqual(pending.batch.entries[0].card.id, f.saleCard); + if (pending.continuation.kind !== 'karamaMarketDiscard') throw Error('Use the original native market cancellation cost'); + assert.equal(pending.continuation.player, f.spender); assert.equal(pending.continuation.card, f.cost); + assert.deepEqual(pending.continuation.use.response, f.originalResponse); + assert.deepEqual(pending.continuation.resume.response, f.originalResponse); + assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null); + assert.deepEqual(resources(game), resources(f.game)); + assert.deepEqual(game.choamMarket ?? null, f.originalMarket); + assert.deepEqual(game.richeseAuction ?? null, f.originalLot); assert.deepEqual(game.richeseBidding ?? null, f.originalRound); + assert.deepEqual(game.auction ?? null, f.originalAuction); + assert.deepEqual(game.deck.map(card => card.id), f.originalDeck); + assert.equal(player(game, f.owner).hand.filter(card => card.id === f.saleCard).length, 1); + if (f.witness) assert.equal(player(game, f.owner).hand.filter(card => card.id === f.witness).length, 1); + assert.equal(game.discard.some(card => card.id === f.saleCard), false); + assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true); + assert.deepEqual(inventory(game), f.originalInventory); return game; +} +function originalChooser(game: Game, f: SemutaNativeMarketsFixture) { + assert.equal(game.phase, 3); assert.equal(game.turn, f.game.turn); + assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.notEqual(game.response?.kind, f.kind); + if (f.kind === 'choamSale') { + assert.equal(game.decision?.kind, 'choamMarket'); assert.equal(game.decision?.player, f.owner); + assert.equal(game.choamMarket?.resume, f.originalMarket!.resume); + assert.equal(game.choamMarket?.sale, undefined); + } else { + assert.equal(game.decision?.kind, 'richeseDeclaration'); assert.equal(game.decision?.player, f.richese); + assert.equal(game.richeseAuction ?? null, null); assert.equal(game.currentAuctionSale ?? null, null); + assert.equal(game.richeseBidding?.blackMarketSold, false); assert.equal(game.richeseBidding?.cacheCanceled, false); + assert.equal(game.richeseBidding?.normalCountBeforeCache, 3); assert.equal(game.richeseBidding?.normalCount, 2); + } + assert.deepEqual(game.richeseCache?.map(card => card.id), f.originalCache); + assert.deepEqual(game.deck.map(card => card.id), f.originalDeck); + assert.deepEqual(inventory(game), f.originalInventory); +} +function prevented(game: Game, f: SemutaNativeMarketsFixture, claimed: boolean) { + assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingKarama ?? null, null); + assert.deepEqual(resources(game), resources(f.game)); + assert.equal(player(game, f.owner).hand.filter(card => card.id === f.saleCard).length, 1); + assert.equal(game.discard.some(card => card.id === f.saleCard), false); + if (f.witness) assert.equal(player(game, f.owner).hand.filter(card => card.id === f.witness).length, 1); + if (f.kind === 'choamSale') { + assert.ok(f.expected); assert.deepEqual(game.choamMarket, f.expected.market); + assert.deepEqual(game.decision, f.expected.decision); + assert.equal(game.choamMarket!.blocked.filter(card => card === f.saleCard).length, 1); + } + assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false); + assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed)); + assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed)); + assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed)); + assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed)); + const steps: { actor: string; action: Action }[] = []; + game = finish(game, f.kind, steps); assert.deepEqual(steps, []); + originalChooser(game, f); + assert.deepEqual(finish(game, f.kind), game); + return game; +} + +for (const variant of variants) for (const claimed of [false, true]) void test( + `${variant.advanced ? 'Advanced' : 'Basic'} native ${variant.kind}${variant.kind === 'choamSale' ? 'worthless' in variant ? ' Worthless2' : ' duplicate3' : ''} cost ${claimed ? 'claim' : 'pass'} retains the original offered card and resumes its native chooser once`, () => { + const f = fixture(variant); nativeSource(f); + const offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event; + const game = prevented(claimed ? claim(offered, f.holder) : pass(offered), f, claimed); + reject(game, f.holder, { type: 'semutaCommit', event }); reject(game, f.holder, { type: 'semutaPass', event }); + reject(game, f.owner, f.nativeAction); + }); + +void test('native CHOAM sale and Black Market allowance controls complete only the actual sale/discard or real unbid lot', () => { + for (const variant of variants) { + const f = fixture(variant); nativeSource(f); + let game = allow(structuredClone(f.game)); + if (f.kind === 'choamSale') { + assert.equal(player(game, f.owner).spice, player(f.game, f.owner).spice + f.price); + assert.equal(player(game, f.owner).hand.some(card => card.id === f.saleCard), false); + assert.equal(game.discard.filter(card => card.id === f.saleCard).length, 1); + if (f.witness) assert.equal(player(game, f.owner).hand.filter(card => card.id === f.witness).length, 1); + if (game.pendingTreacheryDiscard) { + assert.equal(game.pendingTreacheryDiscard.continuation.kind, 'choamSaleDiscard'); + assert.deepEqual(game.pendingTreacheryDiscard.batch.entries.map(entry => entry.card.id), [f.saleCard]); + } + } else { + assert.equal(game.response ?? null, null); assert.ok(game.richeseAuction); + assert.equal(game.richeseAuction.source, 'blackMarket'); assert.equal(game.richeseAuction.outcome ?? null, null); + assert.equal(game.richeseAuction.bid, 0); assert.deepEqual(resources(game), resources(f.game)); + } + const steps: { actor: string; action: Action }[] = []; + game = finish(game, f.kind, steps); originalChooser(game, f); + if (f.kind === 'choamSale') { + assert.ok(steps.every(step => step.action.type === 'semutaPass')); + assert.equal(player(game, f.owner).spice, player(f.game, f.owner).spice + f.price); + assert.equal(game.discard.filter(card => card.id === f.saleCard).length, 1); + } else { + assert.ok(steps.length > 0); assert.ok(steps.every(step => step.action.type === 'richeseBid' && step.action.amount === null)); + assert.deepEqual(resources(game), resources(f.game)); + assert.equal(player(game, f.owner).hand.filter(card => card.id === f.saleCard).length, 1); + } + assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost)); + assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta)); + } +}); + +void test('fresh native-market costs reject stale and underlying replies without paying or disposing the original offered cards', () => { + for (const variant of variants) { + const f = fixture(variant), game = open(f), event = game.pendingTreacheryDiscard!.batch.event; + reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` }); + reject(game, f.spender, { type: 'semutaCommit', event }); + reject(game, f.spender, f.action); reject(game, f.owner, { type: 'passResponse' }); + reject(game, f.owner, f.nativeAction); + reject(game, f.kind === 'choamSale' ? f.owner : f.originalLot!.active!, f.kind === 'choamSale' + ? { type: 'decision', done: true } : { type: 'richeseBid', event: f.originalLot!.event, amount: null }); + assert.deepEqual(resources(game), resources(f.game)); + prevented(pass(game), f, false); + } +}); + +void test('all four minimal profiles answer the actual native sale and Black Market cancellation costs legally', () => { + for (const variant of variants) { + const f = fixture(variant), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event; + for (const difficulty of DIFFICULTIES) { + const own = viewGame(offered, f.holder); own.players.find(player => player.id === f.holder)!.bot = difficulty; + const before = structuredClone(own), action = botActions(own)[0]; assert.ok(action, difficulty); + assert.deepEqual(own, before); + assert.ok(action.type === 'semutaCommit' || action.type === 'semutaPass'); assert.equal(action.event, event); + let game = act(structuredClone(offered), f.holder, action); + const claimed = action.type === 'semutaCommit'; + if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') { + const choosing = viewGame(game, f.holder); choosing.players.find(player => player.id === f.holder)!.bot = difficulty; + const selected = botActions(choosing)[0]; assert.ok(selected); + assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost); + game = act(game, f.holder, selected); + } else if (game.pendingTreacheryDiscard) game = pass(game); + prevented(game, f, claimed); + } + } +});