From 70c95f2686a6d3bfd66b06a0041832b31c08370a Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:21:13 +1100 Subject: [PATCH] Offer Semuta on auction advantage cancellation costs --- docs/CURRENT_STATUS.md | 31 ++- docs/IMPLEMENTATION_STATUS.md | 37 +++ docs/SEMUTA_SOURCE_UPDATE_20260907.md | 72 ++++++ game/engine.ts | 75 +++++- game/reference.ts | 7 + tests/fixture-semuta-auction-inspection.ts | 164 +++++++++++++ tests/fixture-semuta-ixian-auction.ts | 216 ++++++++++++++++++ .../semuta-auction-inspection-engine.test.ts | 162 +++++++++++++ tests/semuta-ixian-auction-engine.test.ts | 174 ++++++++++++++ .../semuta-prevented-gift-reservation.test.ts | 134 +++++++++++ 10 files changed, 1063 insertions(+), 9 deletions(-) create mode 100644 tests/fixture-semuta-auction-inspection.ts create mode 100644 tests/fixture-semuta-ixian-auction.ts create mode 100644 tests/semuta-auction-inspection-engine.test.ts create mode 100644 tests/semuta-ixian-auction-engine.test.ts create mode 100644 tests/semuta-prevented-gift-reservation.test.ts diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 4c9cb1c8..2ce88a02 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -1,6 +1,6 @@ # Current development status -Updated 25 October 2026. **The full twelve-faction goal is unfinished. Basic +Updated 26 October 2026. **The full twelve-faction goal is unfinished. Basic is playable. Advanced, expansion-faction and supported optional-module starts are labelled previews; complete rules/publication acceptance remains gated.** @@ -16,6 +16,35 @@ open mode gates. ## Current checkpoint and work +**Semuta on auction-advantage cancellation costs — 26 October. Prototyped / Partial.** +Printed-role Karama stopping Atreides inspection, Ixian pool preparation or +Advanced Technology now offers its exact used cost before native prevention. +Claim/pass resumes the original typed response once. Original lot/pool, frozen +count, funding, knowledge and Technology attempt remain; no extra inspection, +private Ix choice, exchange or producer replay occurs. The same committed +prevention releases outgoing Semuta's own Technology/gift reservation, allowing +it to claim the counter's cost. Gift prevention first retains Semuta and restores +its parent, then the atomic Semuta exchange spends it; other commitments remain. +[Source and boundaries](SEMUTA_SOURCE_UPDATE_20260907.md#auction-advantage-cancellation-costs--26-october-2026). + +Two disjoint source lanes, bounded native-order review and root integration pass +**15 files,269/269**, types/lint/build and **22 native engine/GameTable SSR/real +Semuta-callback journeys** on unchanged source. Thirty new cases cover normal/ +Black Market inspection, Basic/Advanced undrawn pools, selected Semuta in +Technology and gifts, original income parents, stale/underlying rejection and +all four minimal profiles. Earlier paid cache1 acquisition and actually dealt +Karama reach each source; the fixtures do not install played-state receipts. +Source prefixes: inspection114/121, Black Market115, pool75/80, Technology87, +gift121/128 and income-parent116/123. Type feedback required one explicit test +reaction annotation; the frozen checks pass without changing a rule guard. +Private source-bound report/logs: +`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-auction-frozen/`; +journeys/HTML: adjacent `20261026-semuta-auction-runtime/`. +No saved-room/D1/server/reset/restart/deployment/recovery operation occurs. +These are engine/SSR/callback results, not hydration, DOM/browser/mobile or +deployed evidence. Other producers, broader combinations, complete modes and +final assurance remain unfinished; continue the remaining rule/checklist gaps. + **Semuta on Richese cancellation costs — 25 October. Prototyped / Partial.** Printed-role Karama stopping the compulsory cache auction or allied Richese gift now offers its exact used cost before native prevention finishes. Claim/ diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 7da3f961..2bd7dbff 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,42 @@ # Dune implementation status +## 26 October 2026 — Semuta claims auction-advantage cancellation costs + +**Prototyped / Partial.** Printed-role Karama stopping Atreides inspection, +Ixian ordinary-pool preparation or Advanced Technology now offers the exact +physical cost before native prevention. One typed continuation binds the +original response, lot/pool, count, funding, knowledge and Technology attempt. +Claim/pass resumes once without inspecting, granting the extra Ix choice, +exchanging a card or replaying the producer. Physical stock after the reaction +governs the later native draw; the spent cost is never counted twice. +[Source and boundaries](SEMUTA_SOURCE_UPDATE_20260907.md#auction-advantage-cancellation-costs--26-october-2026). + +When outgoing Semuta itself was selected for the now-prevented Technology or +allied gift, only that reservation releases for this cost response. Native gift +prevention retains Semuta and restores its original parent before the computed +Semuta exchange spends it and takes the counter's cost. The recipient gets +neither card; prevention cannot execute twice. Other parent reservations remain +visible and enforced. Existing persistent controls and minimal legal bots serve +these sources without a strategy change. + +Two disjoint source lanes, bounded native-order review and root integration pass +**15 files,269/269**, types/lint/build and **22 native engine/GameTable SSR/real +Semuta-callback journeys** on unchanged source. Thirty new cases cover ordinary/ +Black Market inspection, Basic/Advanced undrawn pools, outgoing Semuta, native +income parents, stale/underlying rejection and all four minimal profiles. +Actual paid cache1 acquisition and original dealt costs supply every source: +inspection114/121 actions, Black Market115, pool75/80, Technology87, gift121/128 +and income-parent116/123. No played-state hand, receipt or pending cancellation +is installed. Initial static feedback requires one test reaction annotation; +the frozen proof passes without weakening any rule guard. + +Private source-bound report/logs: +`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-auction-frozen/`; +journeys/HTML: adjacent `20261026-semuta-auction-runtime/`. +No saved-room/D1/server/reset/restart/deployment/recovery operation occurs. +These are engine/SSR/callback results; hydration, DOM/browser/mobile, broader +combinations, full modes and deployed/final assurance remain open. + ## 25 October 2026 — Semuta claims Richese cancellation costs **Prototyped / Partial.** Exact physical printed-role Karama stopping the diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index 2675cec5..414e834b 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -2,6 +2,78 @@ 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. +## Auction-advantage cancellation costs — 26 October 2026 + +**Prototyped / Partial.** Exact printed-role Karama stopping Atreides auction +inspection, Ixian auction preparation or Advanced Technology now offers the used +cost through the existing neutral Semuta protocol before native prevention. +The shared `karamaAuctionAdvantageDiscard` binds the original response and lot/ +pool, count, order, funding, selected hand card, attempt-use and knowledge state. +It adds no inspection or replacement entitlement. Existing knowledge is retained. + +The original native cancellation validator performs a pure preflight against +the same source with only its child discard removed. The cost is already in +physical stock; do not supply it again as an unspent card. After claim/pass, +retire the batch and execute the original typed cancellation once. No producer +action, activation discard, draw or exchange is replayed. + +Canceled Atreides inspection preserves the actual normal or native Black Market +lot and its existing bid/participant continuation. Canceled Ix preparation draws +only the native ordinary pool after the cost response, without granting the +extra private Ixian choice. Original frozen count remains; actual physical +stock determines supply. A passed cost can join a later native refill; a claimed +cost is in its new owner's hand and cannot also be drawn. Technology keeps the +original unseen lot and outgoing card, retains its used attempt, clears the +exchange and resumes the native Atreides peek. Existing occupation authority +and independent information entitlements retain their own rules. + +If the original outgoing Technology card is Semuta, the committed cancellation +releases only that reservation for this cost response. The actual native source +stays pending until prevention; Semuta may then spend as itself to take the +counter's used cost. This is not an exchange or a second use of Technology. +The same rule now handles an allied gift whose proposed card is Semuta. After +commitment, native gift prevention records/restores its original parent while +Semuta is still held; the computed Semuta exchange then spends that retained +card and takes the counter's cost atomically. The recipient gets neither card. +The native prevention is not run twice. The reservation-check shadow restores +the gift's saved parent, so its other commitments remain visible. Every other +card/transaction reservation stays enforced; no general reservation bypass is +introduced. + +Evidence: [Atreides sources](../tests/fixture-semuta-auction-inspection.ts), +[inspection cases](../tests/semuta-auction-inspection-engine.test.ts), +[Ixian sources](../tests/fixture-semuta-ixian-auction.ts), +[pool/Technology cases](../tests/semuta-ixian-auction-engine.test.ts). +The [prevented Semuta gift cases](../tests/semuta-prevented-gift-reservation.test.ts) +fork a genuine prior `beforeGift` source and make the actual Semuta gift; +they do not install a canceled gift or move played cards between hands. +Other producers, broader combinations, complete modes, comprehensive assurance +and exact-revision deployed acceptance remain open. + +Frozen **15 files,269/269**, types/lint/build and **22 actual native engine/ +GameTable SSR/real Semuta-callback journeys** pass on unchanged source. New +inspection11, Ixian10 and outgoing-gift9 cases exercise claim/pass, original +normal/Black Market lots, Basic/Advanced undrawn pools, selected outgoing Semuta, +original income parents, stale/underlying rejection and all four minimal profiles. + +Earlier real paid cache1 acquisition supplies Semuta and original dealing supplies +the printed cost. Inspection uses114/121 Basic/Advanced source actions; actual +Advanced Black Market115 preserves its unbid lot. Pool75/80 stops before any +native draw; Technology87 actually declares Shield or the acquired Semuta as +its outgoing card. Gift121/128 and income-parent116/123 fork the native source +before declaration, then actually propose Semuta. Original inventories and +wallets remain physical through the continuations. No played-state receipt, +hand transfer or pending cancellation is installed. + +Initial type feedback needs an explicit live reaction annotation in the Ixian +test driver. Frozen quick, related union, build and runtime then pass without +weakening a rule guard. Private matching source-bound report/logs: +`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-auction-frozen/`; +journeys/HTML: adjacent `20261026-semuta-auction-runtime/`. +No saved-room/database/server/reset/restart/deployment/recovery operation occurs. +Engine/SSR/callback evidence does not certify hydration, DOM/browser/mobile, +broader combinations, complete modes or deployed/final assurance. + ## Richese cancellation costs — 25 October 2026 **Prototyped / Partial.** Printed-role Karama stopping the compulsory Richese diff --git a/game/engine.ts b/game/engine.ts index 48069ae6..56a8845c 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1575,7 +1575,7 @@ export type Game = { stateSignature: string; } | { - kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard'; + kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard' | 'karamaAuctionAdvantageDiscard'; player: string; card: string; use: Extract; @@ -5826,9 +5826,21 @@ function semutaRicheseKaramaResponse(g: Game, response: ResponseWindow): boolean throw error; } } +/** Preflight only: the native cancellation remains the sole owner of any + * draw, peek or exchange. Its just-spent card is already in physical custody. */ +function semutaAuctionAdvantageResponse(g: Game, response: ResponseWindow, player: string): boolean { + if (g.phase !== 3 || !['atreidesAuction','ixAuction','ixTechnology'].includes(response.kind)) return false; + try { + validateKaramaUse({...g,pendingTreacheryDiscard:null,response},getPlayer(g,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' } + { kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard' | 'karamaAuctionAdvantageDiscard' } >; function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinuation) { const battle = c.kind === 'karamaBattleDiscard'; @@ -5838,6 +5850,7 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu const shipment = c.kind === 'karamaShipmentDiscard'; const conversion = c.kind === 'karamaBgConversionCancelDiscard'; const richese = c.kind === 'karamaRicheseAuctionDiscard' || c.kind === 'karamaRicheseGiftDiscard'; + const auctionAdvantage = c.kind === 'karamaAuctionAdvantageDiscard'; return JSON.stringify({ player: c.player, card: c.card, use: c.use, parent: nullentropyParentSignature(g, c.resume, @@ -5894,6 +5907,14 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu active:g.active, ready:g.ready, remaining:g.movementRemaining, shipping:g.karamaShipping, battle:g.battle, } : undefined, + auctionAdvantage: auctionAdvantage ? { + auction:g.auction, ix:g.ixAuction, technology:g.pendingIxTechnology, + technologyTurn:g.ixTechnologyTurn, ixKnown:g.ixAuctionKnown, + round:g.richeseBidding, lot:g.richeseAuction, richeseKnown:g.richesePeekKnown, + sale:g.currentAuctionSale, deck:g.deck, aid:g.aid, + active:g.active, ready:g.ready, homeworlds:g.homeworlds, + inspection:g.homeworldAuctionInspection, occupation:g.homeworldOccupationHistory, + } : undefined, order: g.order, players: g.players.map(p => ({ id: p.id, faction: p.faction, ally: p.ally, @@ -6812,6 +6833,7 @@ function treacheryDiscardIntegrity(g: Game) { continuation?.kind === 'karamaIncomeDiscard' || continuation?.kind === 'karamaRicheseAuctionDiscard' || continuation?.kind === 'karamaRicheseGiftDiscard' || + continuation?.kind === 'karamaAuctionAdvantageDiscard' || continuation?.kind === 'karamaShipmentDiscard' || continuation?.kind === 'karamaBgConversionCancelDiscard') { const c = continuation, entry = batch.entries[0]; @@ -6840,6 +6862,8 @@ function treacheryDiscardIntegrity(g: Game) { semutaRicheseKaramaResponse(g,c.use.response)) || (c.kind === 'karamaRicheseGiftDiscard' && c.use.response.kind === 'richeseGift' && semutaRicheseKaramaResponse(g,c.use.response)) || + (c.kind === 'karamaAuctionAdvantageDiscard' && + semutaAuctionAdvantageResponse(g,c.use.response,c.player)) || (c.kind === 'karamaShipmentDiscard' && semutaGuildShipmentKaramaResponse(g, c.use.response)) || (c.kind === 'karamaBgConversionCancelDiscard' && @@ -7741,6 +7765,7 @@ function semutaOfferSupported( ) { const richeseGiftDiscard = continuation.kind === 'karamaRicheseGiftDiscard'; const richeseDiscard = richeseGiftDiscard || continuation.kind === 'karamaRicheseAuctionDiscard'; + const auctionAdvantageDiscard = continuation.kind === 'karamaAuctionAdvantageDiscard'; const bgDiscard = continuation.kind === 'bgKaramaCostDiscard' || continuation.kind === 'karamaBgConversionCancelDiscard'; const ambassadorDiscard = continuation.kind === 'ambassador'; @@ -7779,7 +7804,7 @@ function semutaOfferSupported( continuation.kind !== 'karamaChoamWorthlessDiscard' && continuation.kind !== 'karamaEmperorShareDiscard' && continuation.kind !== 'karamaIncomeDiscard' && - !richeseDiscard && + !richeseDiscard && !auctionAdvantageDiscard && continuation.kind !== 'karamaShipmentDiscard' && continuation.kind !== 'karamaShipmentPriceDiscard' && continuation.kind !== 'specialKaramaDiscard' && @@ -7816,7 +7841,7 @@ function semutaOfferSupported( continuation.promise.kind === 'shipment' || continuation.promise.kind === 'movement' || continuation.promise.kind === 'battle'))) return false; - if (g.homeworlds && !bgDiscard && !richeseDiscard && continuation.kind !== 'kaitainDiscard' && + if (g.homeworlds && !bgDiscard && !richeseDiscard && !auctionAdvantageDiscard && continuation.kind !== 'kaitainDiscard' && continuation.kind !== 'tupileCleanupDiscard' && continuation.kind !== 'karamaIncomeDiscard' && continuation.kind !== 'karamaShipmentDiscard' && @@ -7919,6 +7944,7 @@ function semutaOfferSupported( continuation.kind === 'karamaEmperorShareDiscard' || continuation.kind === 'karamaIncomeDiscard' || richeseDiscard || + auctionAdvantageDiscard || continuation.kind === 'karamaShipmentDiscard' ? (continuation.kind === 'karamaBattleDiscard' ? semutaBattleKaramaResponse(g, continuation.use.response) @@ -7930,6 +7956,8 @@ function semutaOfferSupported( ? semutaIncomeKaramaResponse(g, continuation.use.response) : richeseDiscard ? semutaRicheseKaramaResponse(g,continuation.use.response) + : auctionAdvantageDiscard + ? semutaAuctionAdvantageResponse(g,continuation.use.response,continuation.player) : continuation.kind === 'karamaShipmentDiscard' ? semutaGuildShipmentKaramaResponse(g, continuation.use.response) : ['choamCharity', 'choamInflation', 'bgCharity'].includes(continuation.resume.response?.kind ?? '')) && @@ -7964,6 +7992,7 @@ function semutaOfferSupported( continuation.kind === 'karamaEmperorShareDiscard' || continuation.kind === 'karamaIncomeDiscard' || richeseDiscard || + auctionAdvantageDiscard || continuation.kind === 'karamaShipmentDiscard' || ((continuation.kind === 'ordinaryCardDiscard' ? ordinaryDiscardIncomeMatches(g, continuation) @@ -7976,7 +8005,7 @@ function semutaOfferSupported( : !continuation.resume.phaseOpening)))) && (richeseGiftDiscard || continuation.kind === 'karamaShipmentDiscard' || continuation.kind === 'specialKaramaDiscard' || bgDiscard || !g.karamaShipping) && - (richeseGiftDiscard || bgDiscard || continuation.kind === 'specialKaramaDiscard' || (continuation.kind === 'ixAllyCard' + (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 @@ -7990,7 +8019,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)) && - (richeseGiftDiscard || bgDiscard || continuation.kind === 'specialKaramaDiscard' || continuation.kind === 'saphoAuctionDiscard' || + (auctionAdvantageDiscard || richeseGiftDiscard || bgDiscard || continuation.kind === 'specialKaramaDiscard' || continuation.kind === 'saphoAuctionDiscard' || continuation.kind === 'ixAllyCard' || (continuation.kind === 'karamaIncomeDiscard' && continuation.use.response.kind === 'emperorIncome') || !g.richeseAuction) && @@ -8047,7 +8076,7 @@ function semutaCandidates(g: Game, owner: Player) { const context = semutaContext(g, owner); const semuta = owner.hand.find(card => card.id === SEMUTA_DRUG_ID && richeseCardDefinition(card)?.card.effect === 'semutaDrug'); - requireRule(semuta && !transferCardBlock(g, owner, semuta), + requireRule(semuta && !semutaSpendingBlock(g, owner, semuta), 'This Semuta Drug is reserved for an unfinished transaction.'); try { return committedSemutaCandidates(context, @@ -8057,6 +8086,21 @@ function semutaCandidates(g: Game, owner: Player) { } } +/** A committed prevention releases only its selected Technology/gift + * reservation for Semuta's cost response. Other commitments remain live. */ +function semutaSpendingBlock(g: Game, owner: Player, card: Card): string | null { + const c=g.pendingTreacheryDiscard?.continuation; + const gift=g.pendingRicheseGift; + const preventedTechnology=c?.kind==='karamaAuctionAdvantageDiscard' && + c.use.response.kind==='ixTechnology' && c.use.response.owner===owner.id && + g.pendingIxTechnology?.card===card.id && card.id===SEMUTA_DRUG_ID; + const preventedGift=c?.kind==='karamaRicheseGiftDiscard' && + c.use.response.kind==='richeseGift' && gift?.intent.owner===owner.id && + gift.intent.cardId===card.id && card.id===SEMUTA_DRUG_ID; + return transferCardBlock(preventedTechnology ? {...g,pendingIxTechnology:null} + : preventedGift ? {...g,...gift!.resume,pendingRicheseGift:null} : g,owner,card); +} + function projectedSemutaReaction(g: Game, me: Player) { const pending = g.pendingTreacheryDiscard; const reaction = pending?.reaction; @@ -8072,7 +8116,7 @@ function projectedSemutaReaction(g: Game, me: Player) { richeseCardDefinition(card)?.card.effect === 'semutaDrug'); const held = !!semuta; const blocked = !held || passed ? null - : transferCardBlock(g, me, semuta!) ?? + : semutaSpendingBlock(g, me, semuta!) ?? (me.hand.length > handLimit(me) ? 'Resolve your excess hand cards before using Semuta Drug.' : pending.batch.entries.every(entry => entry.discardedBy === me.id) @@ -8130,6 +8174,17 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string // Retire before the Semuta transfer or any suffix can draw the fresh card. g.resolvedTreacheryDiscardSequence = pending.sequence; g.pendingTreacheryDiscard = null; + // A stopped gift retains Semuta before Semuta spends as a different effect. + // Native prevention only records/restores here; it cannot advance its parent. + // Other continuations retain their original post-transfer ordering. + const preventedGiftBeforeClaim=!!result && !!owner && next.kind==='karamaRicheseGiftDiscard' && + g.pendingRicheseGift?.intent.owner===owner.id && + g.pendingRicheseGift.intent.cardId===SEMUTA_DRUG_ID; + if (preventedGiftBeforeClaim && next.kind==='karamaRicheseGiftDiscard') { + g.response=next.resume.response; g.decision=next.resume.decision; + g.pendingKarama=next.resume.pendingKarama; g.phaseOpening=next.resume.phaseOpening; + completeKarama(g,getPlayer(g,next.player),next.use,pending.batch.entries[0].card); + } if (result && owner) { owner.hand = result.ownerHand; g.discard = result.discard; @@ -8140,6 +8195,7 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string log(g, `${owner.name} played Semuta Drug and recovered one freshly discarded Treachery Card. The acquired card stays private.`, { faction: owner.faction, name: 'Semuta Drug' }); } + if (preventedGiftBeforeClaim) return; if (next.kind === 'winnerMandatoryDiscard') { finishWinner(g, getPlayer(g, next.player), next.territory, next.optional); return; @@ -8292,6 +8348,7 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string next.kind === 'karamaIncomeDiscard' || next.kind === 'karamaRicheseAuctionDiscard' || next.kind === 'karamaRicheseGiftDiscard' || + next.kind === 'karamaAuctionAdvantageDiscard' || next.kind === 'karamaShipmentDiscard' || next.kind === 'karamaBgConversionCancelDiscard' || next.kind === 'karamaPurchaseDiscard' || @@ -15645,12 +15702,14 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered semutaChoamKaramaResponse(g, use.response) || semutaEmperorShareKaramaResponse(g, use.response) || semutaIncomeKaramaResponse(g, use.response) || semutaRicheseKaramaResponse(g,use.response) || + semutaAuctionAdvantageResponse(g,use.response,p.id) || guildShipmentDiscard || bgConversionCancelDiscard)) { const continuation: KaramaResponseDiscardContinuation = { kind: bgConversionCancelDiscard ? 'karamaBgConversionCancelDiscard' : guildShipmentDiscard ? 'karamaShipmentDiscard' : use.response.kind === 'richeseAuction' ? 'karamaRicheseAuctionDiscard' : use.response.kind === 'richeseGift' ? 'karamaRicheseGiftDiscard' + : ['atreidesAuction','ixAuction','ixTechnology'].includes(use.response.kind) ? 'karamaAuctionAdvantageDiscard' : use.response.kind === 'choamCharity' ? 'karamaCharityDiscard' : use.response.kind === 'choamInflation' ? 'karamaInflationDiscard' diff --git a/game/reference.ts b/game/reference.ts index 5b41471f..81ca309e 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1288,6 +1288,8 @@ export const RULE_TOPICS: RuleTopic[] = [ 'In the Basic native Moritani entry, winner compulsory/optional card discards may pause before the losing ally’s original retention choice. After that ally chooses one eligible played card or declines, native Karama finishes first; the completed unkept-card batch then offers Semuta. A kept or unrelated hand card is never a target. Multiple fresh cards require commitment and one private selection; taking a winner’s card does not add it to the original retention pool. Advanced Moritani, Nexus retention and unresolved casualty parents remain separate.', 'A printed Karama canceling native Harkonnen capture, CHOAM Auditor or quoted battle income, Ixian casualty substitution, or Basic Moritani allied retention now offers that exact public cost before the original cancellation finishes. Claim or Continue restores the saved response and cancels once. Acquiring the cost card cannot restore a captive, inspect a hand, pay the canceled income, exchange casualties or keep the proposed allied card. Later native card cleanup and markets still finish; Worthless conversion, other cancellations and module combinations remain separate.', 'A printed-role Karama stopping Richese’s compulsory cache auction or allied card gift also offers its exact used cost before prevention completes. Claim or Continue resumes that original prevention once. The cache keeps its cards and frozen ordinary count, including any completed Black Market reduction; a prevented gift stays with Richese and its original parent resumes. Recovering Karama does not repeat the attempt, transfer the gift or replay a paid purchase. Existing gift retry protection remains.', + 'When Semuta itself was proposed as the now-prevented allied gift, its donor may spend it to claim that counter’s used cost. Prevention first retains Semuta and restores the original parent; the same committed response then spends Semuta and takes the cost, without a second prevention or transfer to the ally. Passing keeps Semuta. Other commitments remain enforced.', + 'Printed-role Karama stopping Atreides auction inspection, Ixian auction preparation or Advanced Technology also offers its exact cost before prevention. Claim or Continue resumes the original lot or ordinary draw once, without new inspection, extra Ix choice or exchange. Technology stays used and its original unseen lot remains; the native Atreides peek follows. If outgoing Semuta was selected for that now-prevented exchange, only that reservation releases so Semuta can claim the counter’s cost. Other commitments and independent knowledge remain.', '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.', 'The fresh semuta --nexus-cards entry retains original native E1/E2 Nexus setup, optionally explicit Skills and original Tech/Advanced Strongholds. There is no starting Nexus hand: an actual qualifying closing draw follows a Nexus and settled alliance. CHOAM Secret Ally with CHOAM absent spends a Worthless and its Nexus for two bank spice before the public discard offer; CHOAM Betrayal with CHOAM seated commits one random actual hand-card discard without compensation before its private offer. Claim or Continue retires the original receipt once, without another payment or sample. Separate Nexus cards never become Treachery targets. Homeworlds, Discovery, E3, played-save conversion and public starts stay gated.', 'The same original native Nexus entry offers the actual CHOAM Cunning fuel discard for Kulon, Gamont, Baliset, La La La and Jubba. Any eligible held Treachery card keeps its printed identity and private face; there is no fabricated named Worthless. Original cancellation retains fuel and the spent Nexus. After allowance, Commit or Continue resumes the selected effect and parent once: one range bonus, one typed force return and original closing owner, stopped ordinary movement/free revival, or one threatened-territory storm protection. Jubba protects CHOAM forces, not spice; unprotected forces and covered spice still suffer the original storm. Kull retains its separate guard.', @@ -1417,6 +1419,11 @@ export const RULE_TOPICS: RuleTopic[] = [ 'tests/semuta-richese-cache-karama-engine.test.ts', 'tests/fixture-semuta-richese-gift-karama.ts', 'tests/semuta-richese-gift-karama-engine.test.ts', + 'tests/fixture-semuta-auction-inspection.ts', + 'tests/semuta-auction-inspection-engine.test.ts', + 'tests/fixture-semuta-ixian-auction.ts', + 'tests/semuta-ixian-auction-engine.test.ts', + 'tests/semuta-prevented-gift-reservation.test.ts', 'tests/semuta-homeworld-bg-charity.test.ts', 'tests/semuta-homeworld-emperor-share.test.ts', 'tests/semuta-homeworld-capture-karama.test.ts', diff --git a/tests/fixture-semuta-auction-inspection.ts b/tests/fixture-semuta-auction-inspection.ts new file mode 100644 index 00000000..5919c6ce --- /dev/null +++ b/tests/fixture-semuta-auction-inspection.ts @@ -0,0 +1,164 @@ +import assert from 'node:assert/strict'; +import { + createGame, initializeSemutaGameForAudit, joinGame, newPlayer, type Action, type Game, +} from '../game/engine'; +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'; + +export type SemutaAuctionInspectionStep = { actor: string; action: Action }; +export const actSemutaAuctionInspection = actSemutaRicheseCacheKarama; +export const nextSemutaAuctionInspection = nextSemutaRicheseCacheKarama; +export const semutaAuctionInspectionClean = semutaRicheseCacheKaramaClean; +export const semutaAuctionInspectionInventory = semutaRicheseCacheKaramaInventory; +export const semutaAuctionInspectionPlayer = semutaRicheseCacheKaramaPlayer; + +export function advanceSemutaAuctionInspection(game: Game, until: (state: Game) => boolean, + actions: SemutaAuctionInspectionStep[] = []): Game { + for (let count = 0; count < 1800; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'The native inspection source must reach its next control'); + const next = nextSemutaAuctionInspection(game); + assert.ok(next, `Supply the original owned control at ${game.turn}/${game.phase}/${game.decision?.kind}`); + game = actSemutaAuctionInspection(game, next.actor, next.action, actions); + } + throw Error('The original Semuta/inspection programme did not reach its finite boundary'); +} + +/** Real public Basic start or original undealt Advanced Semuta preview. A + * printed Karama is genuinely dealt to Emperor; CHOAM buys Semuta in an earlier + * cache1 auction. No played card, balance, force, clock or response is installed. */ +export function createSemutaAuctionInspectionFixture(options: { advanced?: boolean; blackMarket?: boolean } = {}) { + const advanced = options.advanced ?? false, blackMarket = options.blackMarket ?? false; + assert.ok(!blackMarket || advanced, 'The original Black Market requires Advanced play'); + const richese = 'richese', holder = 'choam', owner = 'atreides', spender = 'emperor'; + let game = createGame('SEMUTAAUCTIONINSPECTION', newPlayer(richese, 'Richese', 'richese'), advanced, ['choam']); + for (const faction of [holder, owner, spender] as const) joinGame(game, newPlayer(faction, faction, faction)); + const initial = structuredClone(game), actions: SemutaAuctionInspectionStep[] = []; + const act = (actor: string, action: Action) => { game = actSemutaAuctionInspection(game, actor, action, actions); }; + const reach = (until: (state: Game) => boolean) => { game = advanceSemutaAuctionInspection(game, until, actions); }; + 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 = semutaAuctionInspectionInventory(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 ordinary = game.deck.filter(card => card.kind === 'projectile').slice(0, 3); assert.equal(ordinary.length, 3); + const selected = [...ordinary, 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 { 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(player => player.hand.map(card => card.id)), selected.map(card => card.id)); + assert.ok(semutaAuctionInspectionPlayer(game, spender).hand.some(card => card.id === cost)); + const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort(); + const front: Game['spiceDeck'] = [], spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = []; + for (let position = 0; position < (advanced ? 4 : 2); position++) { + const from = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(from >= 0); + const card = game.spiceDeck.splice(from, 1)[0]; front.push(card); + spiceOrder.push({ position, from, card: structuredClone(card) }); + } + game.spiceDeck.unshift(...front); + assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice); + let beforeBid: Game | undefined, bidStep: SemutaAuctionInspectionStep | undefined, bidAt = -1; + for (let count = 0; count < 900 && !semutaAuctionInspectionPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) { + const next = nextSemutaAuctionInspection(game); assert.ok(next); + if (next.actor === holder && next.action.type === 'richeseBid' && next.action.amount === 1) { + beforeBid = structuredClone(game); bidStep = structuredClone(next); bidAt = actions.length; + } + act(next.actor, next.action); + } + assert.ok(beforeBid && bidStep && bidAt >= 0, 'The actual earlier cache auction must give CHOAM Semuta'); + const acquired = structuredClone(game), acquisition = { + before: beforeBid, bid: bidStep, steps: structuredClone(actions.slice(bidAt)), after: acquired, + }; + assert.equal(semutaAuctionInspectionPlayer(beforeBid, holder).spice - semutaAuctionInspectionPlayer(game, holder).spice, 1); + assert.equal(semutaAuctionInspectionPlayer(game, richese).spice - semutaAuctionInspectionPlayer(beforeBid, richese).spice, 1); + assert.equal(game.richeseCache!.length, 9); + let beforeBlackMarket: Game | null = null, blackMarketStep: SemutaAuctionInspectionStep | null = null; + if (blackMarket) { + reach(state => state.turn === 2 && state.phase === 3 && !state.response && !state.phaseOpening && + state.decision?.kind === 'richeseBlackMarket'); + beforeBlackMarket = structuredClone(game); + const selected = semutaAuctionInspectionPlayer(game, richese).hand.find(card => card.kind === 'projectile'); assert.ok(selected); + blackMarketStep = { actor: richese, action: { type: 'decision', event: game.richeseBidding!.event, + card: selected.id, method: 'normal', direction: 'clockwise' } }; + act(blackMarketStep.actor, blackMarketStep.action); + } + reach(state => state.turn === 2 && state.phase === 3 && state.response?.kind === 'atreidesAuction' && + (blackMarket ? state.richeseAuction?.source === 'blackMarket' : !!state.auction && !state.richeseAuction)); + assert.equal(game.response?.owner, owner); + assert.ok(semutaAuctionInspectionPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID)); + assert.ok(semutaAuctionInspectionPlayer(game, spender).hand.some(card => card.id === cost)); + const origin = blackMarket ? 'blackMarket' as const : 'normal' as const; + const originalLot = structuredClone(blackMarket ? game.richeseAuction! : game.auction!); + const cards = blackMarket ? [game.richeseAuction!.cardId] : game.auction!.cards.map(card => card.id); + const nextBidder = blackMarket ? game.richeseAuction!.active : game.auction!.active; assert.ok(nextBidder); + assert.equal(blackMarket ? game.richesePeekKnown : game.auction!.peekKnown, false); + assert.deepEqual(semutaAuctionInspectionInventory(game), originalInventory); + const source = structuredClone(game), action: Action = { type: 'card', card: cost, mode: 'cancel' }; + return { + game, source, gameBeforeCost: source, action, advanced, blackMarket, origin, + holder, spender, owner, richese, cost, karama: cost, semuta: SEMUTA_DRUG_ID, + originalLot, cards, count: cards.length, nextBidder, originalResponse: structuredClone(game.response), + cache: game.richeseCache!.map(card => card.id), originalRound: structuredClone(game.richeseBidding), + initial, lobby, setup, initialization, beforeStartingDeal, afterSetup, dealOrder, spiceOrder, + beforeBlackMarket, blackMarketStep, acquired, acquisition, + originalInventory, inventory: originalInventory, originalSpice, + actions, sourceActions: actions, sourceActionCount: actions.length, + staging: [ + advanced + ? 'Existing Advanced Semuta initializer acts only on the ready undealt original R/C/A/E lobby; no new public entry or played preview flag is installed.' + : 'Actual public bare Basic R/C/A/E ready/start initializes its existing Semuta preview before original setup.', + 'Select only four unopened original starting positions, including the printed main-deck Karama genuinely dealt to Emperor, and the first two Basic/four Advanced original land Spice positions. Scoped native scalar lotteries retain original physical components and all actual setup/action owners.', + 'CHOAM buys the original Semuta from the first native Once Around cache auction for one spice paid once to Richese. Original first-turn normal lots, Revival, combined turns and Mentat finish through native controls; no played hand, wallet, force, clock, auction or response is assigned.', + blackMarket + ? 'At the real second-turn Advanced Black Market, Richese offers its actual original dealt projectile using normal bidding. Stop at the real still-unseen Atreides inspection before bidding, payment, delivery or declaration. The offered card remains physically held by Richese.' + : 'The actual second-turn compulsory cache auction completes through native pass/remove controls. Stop at the first real normal-pool Atreides inspection before any bid, peek, payment or acquisition.', + ], + }; +} +export type SemutaAuctionInspectionFixture = ReturnType; + +export function passSemutaAuctionInspection(game: Game, actions: SemutaAuctionInspectionStep[] = []): Game { + const event = game.pendingTreacheryDiscard?.batch.event; assert.ok(event); + for (let count = 0; game.pendingTreacheryDiscard?.batch.event === event && count < game.players.length; count++) { + const pending: NonNullable = game.pendingTreacheryDiscard!; + const reaction = pending.reaction; assert.ok(reaction?.stage === 'offer'); + const player = game.players.find(player => !reaction.passed.includes(player.id)); assert.ok(player); + game = actSemutaAuctionInspection(game, player.id, { type: 'semutaPass', event }, actions); + } + assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event); return game; +} +export function claimSemutaAuctionInspection(game: Game, fixture: Pick, + actions: SemutaAuctionInspectionStep[] = []): Game { + const pending = game.pendingTreacheryDiscard; assert.ok(pending && pending.batch.entries.length === 1); + const event = pending.batch.event; + assert.equal(pending.batch.entries[0].card.id, fixture.cost); + game = actSemutaAuctionInspection(game, fixture.holder, { type: 'semutaCommit', event }, actions); + if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') + game = actSemutaAuctionInspection(game, fixture.holder, { type: 'semutaSelect', event, card: fixture.cost }, actions); + return game; +} +/** The used-cost callback completes the native prevention itself. Do not bid, + * reveal the card or consume a later response in this finish helper. */ +export function finishSemutaAuctionInspection(game: Game): Game { + assert.ok(!game.pendingTreacheryDiscard, 'Resolve the original fresh cost first'); + assert.notEqual(game.response?.kind, 'atreidesAuction', 'The original inspection prevention must complete once'); + return game; +} diff --git a/tests/fixture-semuta-ixian-auction.ts b/tests/fixture-semuta-ixian-auction.ts new file mode 100644 index 00000000..7fac3221 --- /dev/null +++ b/tests/fixture-semuta-ixian-auction.ts @@ -0,0 +1,216 @@ +import assert from 'node:assert/strict'; +import { + createGame, initializeSemutaGameForAudit, joinGame, newPlayer, type Action, type Game, +} from '../game/engine'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { actTruthMovement } from './fixture-truth-movement'; +import { nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early'; +import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus'; + +export type SemutaIxianAuctionStep = { actor: string; action: Action }; +export type SemutaIxianAuctionKind = 'ixAuction' | 'ixTechnology'; +export const actSemutaIxianAuction = actTruthMovement; +export const semutaIxianAuctionPlayer = (game: Game, id: string) => { + const player = game.players.find(player => player.id === id); assert.ok(player); return player; +}; +export const semutaIxianAuctionClean = (game: Game) => !game.response && !game.phaseOpening && + !game.decision && !game.truthtrance && !game.pendingTreacheryDiscard; +/** Original physical zones only; inspected/known pool copies are receipts. */ +export function semutaIxianAuctionInventory(game: Game) { + const ids = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []), + ...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []), ...game.players.flatMap(p => p.hand), + ...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])] + .map(card => card.id).sort(); + assert.equal(new Set(ids).size, ids.length, 'Each original main/cache card has one physical zone'); return ids; +} +/** The original source owns no automatic Semuta decision. Native pool return, + * declined optional Technology, initial setup and cache bids remain real acts. */ +export function nextSemutaIxianAuctionStep(game: Game, buyer = 'choam'): SemutaIxianAuctionStep | null { + if (game.pendingTreacheryDiscard || game.truthtrance) return null; + if (!game.response && !game.phaseOpening) { + const d = game.decision; + if (d?.kind === 'ixSetup') { + const card = game.ixSetupCards?.find(card => card.kind === 'shield'); assert.ok(card); + return { actor: d.player, action: { type: 'decision', card: card.id } }; + } + if (d?.kind === 'ixAuction') { + const card = game.ixAuction?.cards[0]; assert.ok(card); + return { actor: d.player, action: { type: 'decision', card: card.id, position: 'bottom' } }; + } + if (d?.kind === 'ixTechnology') return { actor: d.player, action: { type: 'decision', decline: true } }; + if (d?.kind === 'choamMarket' || d?.kind === 'choamMentat') + return { actor: d.player, action: { type: 'decision', done: true } }; + if (d?.kind === 'auctionPayment') return { actor: d.player, action: { type: 'decision', karama: false } }; + if (!d && game.phase === 5) return { actor: game.active!, action: { type: 'endMovement' } }; + } + const lot = game.richeseAuction; + if (semutaIxianAuctionClean(game) && lot?.cardId === SEMUTA_DRUG_ID && !lot.outcome && + lot.active === buyer && lot.bid === 0) + return { actor: buyer, action: { type: 'richeseBid', event: lot.event, amount: 1, allyPayment: 0 } }; + return nextTruthMovementNativeEarlyStep(game, { + position: 'first', method: 'onceAround', direction: 'clockwise', keepUnbid: false, + card: game.richeseCache?.find(card => card.id === SEMUTA_DRUG_ID)?.id ?? game.richeseCache?.[0]?.id, + }); +} +export function advanceSemutaIxianAuction(game: Game, until: (state: Game) => boolean, + actions: SemutaIxianAuctionStep[] = [], buyer = 'choam'): Game { + for (let count = 0; count < 1600; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished'); + const next = nextSemutaIxianAuctionStep(game, buyer); + assert.ok(next, 'Choose the actual original fresh-discard reaction before continuing'); + game = actSemutaIxianAuction(game, next.actor, next.action, actions); + } + throw Error('The native Ixian auction programme did not reach its finite boundary'); +} +/** Original response acknowledgements only, one named opportunity at a time. */ +export function allowSemutaIxianAuction(game: Game, kind: string, + actions: SemutaIxianAuctionStep[] = []): Game { + for (let count = 0; game.response?.kind === kind && count < game.players.length; count++) { + const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor); + game = actSemutaIxianAuction(game, actor.id, { type: 'passResponse' }, actions); + } + assert.notEqual(game.response?.kind, kind); return game; +} +export function passSemutaIxianAuction(game: Game, actions: SemutaIxianAuctionStep[] = []): Game { + const event = game.pendingTreacheryDiscard?.batch.event; assert.ok(event); + for (let count = 0; game.pendingTreacheryDiscard?.batch.event === event && count < game.players.length; count++) { + const pending: NonNullable = game.pendingTreacheryDiscard!; + const reaction = pending.reaction; assert.ok(reaction?.stage === 'offer'); + const actor = game.players.find(p => !reaction.passed.includes(p.id)); assert.ok(actor); + game = actSemutaIxianAuction(game, actor.id, { type: 'semutaPass', event }, actions); + } + assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event); return game; +} +export function claimSemutaIxianAuction(game: Game, holder: string, + actions: SemutaIxianAuctionStep[] = []): Game { + const pending = game.pendingTreacheryDiscard; assert.ok(pending && pending.batch.entries.length === 1); + const event = pending.batch.event, card = pending.batch.entries[0].card.id; + game = actSemutaIxianAuction(game, holder, { type: 'semutaCommit', event }, actions); + if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') + game = actSemutaIxianAuction(game, holder, { type: 'semutaSelect', event, card }, actions); + return game; +} +/** After prevention, decline only a fresh optional Technology offer and allow + * the original Atreides peek. Stop before any normal bid; never replay Ix use. */ +export function finishSemutaIxianAuction(game: Game, actions: SemutaIxianAuctionStep[] = []): Game { + assert.ok(!game.pendingTreacheryDiscard, 'Answer the original cost reaction first'); + assert.ok(!['ixAuction', 'ixTechnology'].includes(game.response?.kind ?? ''), 'The original Ixian prevention must finish first'); + if (!game.response && game.decision?.kind === 'ixTechnology') + game = actSemutaIxianAuction(game, game.decision.player, { type: 'decision', decline: true }, actions); + if (game.response?.kind === 'atreidesAuction') game = allowSemutaIxianAuction(game, 'atreidesAuction', actions); + assert.ok(semutaIxianAuctionClean(game)); return game; +} + +/** Native public Basic start, or original Advanced Semuta initializer, then + * actual Ix setup/Storm/HMS and paid first cache. No played custody, force, + * wallet, phase, queue, response, pool or Technology receipt is installed. */ +export function createSemutaIxianAuctionFixture(options: { + advanced?: boolean; kind?: SemutaIxianAuctionKind; holder?: 'choam' | 'ixians'; +} = {}) { + const kind = options.kind ?? 'ixAuction', advanced = options.advanced ?? kind === 'ixTechnology'; + assert.ok(kind !== 'ixTechnology' || advanced, 'Native Technology is Advanced only'); + const owner = 'ixians', holder = options.holder ?? 'choam', spender = 'atreides', seller = 'richese', observer = 'emperor'; + let game = createGame('SEMUTAIXIANAUCTION', newPlayer(seller, 'Richese', 'richese'), advanced, ['choam', 'ix']); + for (const faction of ['choam', 'ixians', 'atreides', 'emperor'] as const) joinGame(game, newPlayer(faction, faction, faction)); + const actions: SemutaIxianAuctionStep[] = []; + const act = (actor: string, action: Action) => { game = actSemutaIxianAuction(game, actor, action, actions); }; + const reach = (until: (state: Game) => boolean) => { game = advanceSemutaIxianAuction(game, until, actions, holder); }; + 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 ? 'explicit-original-semuta-initializer' : '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)); + const originalInventory = semutaIxianAuctionInventory(game); assert.equal(originalInventory.length, 57); + const karamas = game.deck.filter(card => card.effect === 'karama' && !card.id.startsWith('ix-')); + assert.equal(karamas.length, 2); + const ordinary = game.deck.filter(card => card.kind === 'projectile' && !card.effect).slice(0, 2); + const shield = game.deck.find(card => card.kind === 'shield'); assert.equal(ordinary.length, 2); assert.ok(shield); + // Ix actually selects its inspected Shield. The original max scalar lottery + // leaves the four remaining inspected cards in their existing native order. + const inspected = [ordinary[0].id, ordinary[1].id, shield.id, karamas[0].id, karamas[1].id]; + const dealOrder = inspected.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, position, from }; + }); + const beforeStartingDeal = structuredClone(game), cost = karamas[0].id, observerKarama = karamas[1].id; + let ixSetup: { before: Game; step: SemutaIxianAuctionStep; after: Game } | null = null; + for (let count = 0; game.status === 'setup' && count < 150; count++) { + const next = nextSemutaIxianAuctionStep(game, holder); assert.ok(next); + const before = game.decision?.kind === 'ixSetup' ? structuredClone(game) : null; + act(next.actor, next.action); + if (before) ixSetup = { before, step: structuredClone(next), after: structuredClone(game) }; + } + assert.equal(game.status, 'playing'); assert.ok(ixSetup); + const afterSetup = structuredClone(game); + assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), inspected); + assert.ok(semutaIxianAuctionPlayer(game, spender).hand.some(card => card.id === cost)); + const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort(), front: Game['spiceDeck'] = []; + const spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = []; + for (let position = 0; position < (advanced ? 2 : 1); position++) { + const from = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(from >= 0); + const card = game.spiceDeck.splice(from, 1)[0]; front.push(card); spiceOrder.push({ position, from, card: structuredClone(card) }); + } + game.spiceDeck.unshift(...front); + assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice); + let beforeBid: Game | undefined, bid: SemutaIxianAuctionStep | undefined, bidAt = -1; + for (let count = 0; count < 900 && !semutaIxianAuctionPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) { + const next = nextSemutaIxianAuctionStep(game, holder); 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.ok(semutaIxianAuctionPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID)); + assert.equal(semutaIxianAuctionPlayer(beforeBid, holder).spice - semutaIxianAuctionPlayer(game, holder).spice, 1); + assert.equal(semutaIxianAuctionPlayer(game, seller).spice - semutaIxianAuctionPlayer(beforeBid, seller).spice, 1); + reach(state => state.response?.kind === 'ixAuction' && !state.phaseOpening && !state.pendingTreacheryDiscard); + const beforePool = structuredClone(game), poolCount = game.ixAuction!.count; + assert.equal(game.turn, 1); assert.deepEqual(game.ixAuction!.cards, []); assert.ok(poolCount > 0); + assert.ok(!game.auction); assert.ok(game.mobileStronghold?.location); + let poolSelection: { before: Game; steps: SemutaIxianAuctionStep[]; after: Game; card: string } | null = null; + let technology: { before: Game; step: SemutaIxianAuctionStep; after: Game } | null = null; + if (kind === 'ixTechnology') { + const before = structuredClone(game), start = actions.length; + game = allowSemutaIxianAuction(game, 'ixAuction', actions); + assert.equal(game.decision?.kind, 'ixAuction'); + const returned = game.ixAuction!.cards[0]; assert.ok(returned); + act(owner, { type: 'decision', card: returned.id, position: 'bottom' }); + assert.equal(game.decision?.kind, 'ixTechnology'); + poolSelection = { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game), card: returned.id }; + const selected = semutaIxianAuctionPlayer(game, owner).hand.find(card => + card.id === (holder === owner ? SEMUTA_DRUG_ID : shield.id)); assert.ok(selected); + const declaration: SemutaIxianAuctionStep = { actor: owner, action: { type: 'decision', card: selected.id } }; + const beforeTechnology = structuredClone(game); act(declaration.actor, declaration.action); + technology = { before: beforeTechnology, step: declaration, after: structuredClone(game) }; + assert.equal(game.response?.kind, 'ixTechnology'); assert.equal(game.ixTechnologyTurn, game.turn); + assert.equal(game.pendingIxTechnology?.card, selected.id); + } + assert.equal(game.response?.kind, kind); assert.equal(game.response?.owner, owner); + assert.ok(semutaIxianAuctionPlayer(game, spender).hand.some(card => card.id === cost)); + assert.ok(semutaIxianAuctionPlayer(game, observer).hand.some(card => card.id === observerKarama)); + assert.deepEqual(semutaIxianAuctionInventory(game), originalInventory); + const source = structuredClone(game), action: Action = { type: 'card', card: cost, mode: 'cancel' }; + return { game, source, beforeCost: source, kind, advanced, holder, spender, owner, ixians: owner, atreides: spender, + observer, observerKarama, seller, cost, karama: cost, card: cost, semuta: SEMUTA_DRUG_ID, action, + lobby, setup, initialization, beforeStartingDeal, afterSetup, inspected, dealOrder, ixSetup, spiceOrder, + acquired, acquisition, beforePool, poolCount, poolSelection, technology, + declaredCard: game.pendingIxTechnology?.card ?? null, + selectedSemuta: kind === 'ixTechnology' && holder === owner, + lotCard: game.auction?.cards[game.auction.index]?.id ?? null, + originalInventory, originalCards: originalInventory, actions, steps: actions, sourceActionCount: actions.length, + staging: [ + 'Actual Richese/CHOAM/Ixians/Atreides/Emperor setup uses the public bare Basic start or existing explicit Advanced Semuta initializer. Original ix+choam main47/cache10 and native setup forces/HMS remain intact; no Basic game is converted after start.', + `Only original unopened setup-pool positions, land Spice positions and scalar lotteries are selected. Ix actually chooses the inspected Shield; its real remainder shuffle deals both original printed Karama cards to Atreides/Emperor. ${holder} really pays cache1 for Semuta before the native first ordinary Ixian pool opens.`, + kind === 'ixAuction' ? 'Stop at the native undrawn pool response: frozen count, zero drawn cards, no auction and no private pool choice. The cancellation cost and subsequent ordinary draw remain undeclared.' : `Actually allow the original Ixian pool response, return one actual inspected card to the deck bottom, then declare the ${holder === owner ? 'genuinely acquired Semuta itself' : 'selected original Shield'} exchange through the native Technology chooser. Stop after its once-use is spent but before cancellation, exchange or Atreides inspection.`, + 'No played hand, wallet, army, phase, queue, receipt, pool result or once-use is assigned. Claim/pass must resolve the actual used printed cost before the original native prevention continues.', + ] }; +} +export type SemutaIxianAuctionFixture = ReturnType; diff --git a/tests/semuta-auction-inspection-engine.test.ts b/tests/semuta-auction-inspection-engine.test.ts new file mode 100644 index 00000000..babc1b61 --- /dev/null +++ b/tests/semuta-auction-inspection-engine.test.ts @@ -0,0 +1,162 @@ +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 { + createSemutaAuctionInspectionFixture as fixture, actSemutaAuctionInspection as act, + passSemutaAuctionInspection as pass, claimSemutaAuctionInspection as claim, + finishSemutaAuctionInspection as finish, semutaAuctionInspectionInventory as inventory, + semutaAuctionInspectionPlayer as player, type SemutaAuctionInspectionFixture, +} from './fixture-semuta-auction-inspection'; + +const resources = (game: Game) => ({ + players: game.players.map(player => ({ id: player.id, spice: player.spice, + reserves: player.reserves, tanks: player.tanks, forces: player.forces, elites: player.elites, + moved: player.moved, shipped: player.shipped, revived: player.revived, ally: player.ally })), + turn: game.turn, phase: game.phase, order: game.order, active: game.active, ready: game.ready, +}); +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: SemutaAuctionInspectionFixture) { + assert.equal(f.initial.status, 'lobby'); 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.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama')); + assert.ok(f.acquisition.steps.some(step => step.actor === f.holder && step.action.type === 'richeseBid' && step.action.amount === 1)); + assert.ok(player(f.acquisition.after, f.holder).hand.some(card => card.id === f.semuta)); + 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.turn, 2); assert.equal(f.game.phase, 3); + assert.equal(f.game.response?.kind, 'atreidesAuction'); assert.equal(f.game.response?.owner, f.owner); + 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.blackMarket ? f.game.richesePeekKnown : f.game.auction!.peekKnown, false); + assert.equal(f.sourceActionCount, f.actions.length); + assert.deepEqual(inventory(f.game), f.originalInventory); +} +function sameOriginalLot(game: Game, f: SemutaAuctionInspectionFixture) { + assert.deepEqual(f.blackMarket ? game.richeseAuction : game.auction, f.originalLot); + assert.deepEqual(game.richeseBidding, f.originalRound); + assert.deepEqual(game.richeseCache!.map(card => card.id), f.cache); + assert.equal(game.active, f.nextBidder); + assert.equal(f.blackMarket ? game.richeseAuction!.active : game.auction!.active, f.nextBidder); + assert.equal(f.blackMarket ? game.richesePeekKnown : game.auction!.peekKnown, false); + assert.deepEqual(f.blackMarket ? [game.richeseAuction!.cardId] : game.auction!.cards.map(card => card.id), f.cards); + assert.equal(f.blackMarket ? game.richeseAuction!.bid : game.auction!.bid, 0); + assert.equal(f.blackMarket ? game.richeseAuction!.bidder : game.auction!.bidder, null); +} +function open(f: SemutaAuctionInspectionFixture) { + const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard; + assert.ok(pending); assert.equal(pending.reaction?.stage, 'offer'); + assert.equal(pending.continuation.kind, 'karamaAuctionAdvantageDiscard'); + assert.equal(pending.batch.cause, 'karama:atreidesAuction'); + assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, public: entry.publicFace })), + [{ card: f.cost, by: f.spender, public: true }]); + assert.equal(game.discard.filter(card => card.id === f.cost).length, 1); + assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false); + assert.equal(game.response, null, 'The original inspection is parked until its used-cost reaction finishes'); + if (pending.continuation.kind !== 'karamaAuctionAdvantageDiscard') throw Error('Use the native auction-advantage cost continuation'); + assert.equal(pending.continuation.player, f.spender); assert.equal(pending.continuation.card, f.cost); + assert.equal(pending.continuation.use.kind, 'cancel'); + assert.equal(pending.continuation.use.response.kind, 'atreidesAuction'); + assert.equal(pending.continuation.use.response.owner, f.owner); + assert.deepEqual(pending.continuation.resume.response, f.originalResponse); + assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true); + sameOriginalLot(game, f); + assert.deepEqual(resources(game), resources(f.game)); + assert.deepEqual(inventory(game), f.originalInventory); + return game; +} +function prevented(state: Game, f: SemutaAuctionInspectionFixture, claimed: boolean) { + const game = finish(state); + assert.equal(game.response, null); assert.equal(game.decision, null); + assert.ok(!game.pendingTreacheryDiscard); assert.ok(!game.pendingKarama); + sameOriginalLot(game, f); + assert.deepEqual(resources(game), resources(f.game)); + 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)); + assert.deepEqual(inventory(game), f.originalInventory); + return game; +} + +for (const advanced of [false, true]) for (const claimed of [false, true]) void test( + `${advanced ? 'Advanced' : 'Basic'} actual paid Semuta ${claimed ? 'claims' : 'passes'} printed Karama before normal Atreides inspection prevention resumes`, () => { + const f = fixture({ advanced }); nativeSource(f); + assert.equal(f.origin, 'normal'); assert.equal(f.game.auction!.index, 0); + const pending = open(f), event = pending.pendingTreacheryDiscard!.batch.event; + const game = prevented(claimed ? claim(pending, f) : pass(pending), f, claimed); + reject(game, f.holder, { type: 'semutaCommit', event }); + reject(game, f.holder, { type: 'semutaPass', event }); + }); + +for (const claimed of [false, true]) void test( + `Advanced unseen native Black Market inspection preserves its exact lot when the printed cost is ${claimed ? 'claimed' : 'passed'}`, () => { + const f = fixture({ advanced: true, blackMarket: true }); nativeSource(f); + assert.ok(f.beforeBlackMarket && f.blackMarketStep); + assert.equal(f.game.richeseAuction!.source, 'blackMarket'); + assert.equal(f.game.richeseAuction!.method, 'normal'); + assert.ok(player(f.game, f.richese).hand.some(card => card.id === f.cards[0])); + const pending = open(f), game = prevented(claimed ? claim(pending, f) : pass(pending), f, claimed); + assert.ok(player(game, f.richese).hand.some(card => card.id === f.cards[0])); + assert.equal(game.richeseBidding!.blackMarketSold, false); + assert.equal(game.richeseBidding!.normalCount, null); + }); + +for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} native allowance control actually grants the original normal-lot peek without discarding Karama`, () => { + const f = fixture({ advanced }); nativeSource(f); + let game = structuredClone(f.game); + for (let count = 0; game.response?.kind === 'atreidesAuction' && count < game.players.length; count++) { + const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor); + game = act(game, actor.id, { type: 'passResponse' }); + } + assert.equal(game.response, null); assert.equal(game.auction!.peekKnown, true); + assert.ok(!game.pendingTreacheryDiscard); + assert.deepEqual({ ...game.auction!, peekKnown: false }, f.originalLot); + 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(resources(game), resources(f.game)); + assert.deepEqual(inventory(game), f.originalInventory); +}); + +for (const blackMarket of [false, true]) void test(`${blackMarket ? 'Black Market' : 'normal'} suspended inspection rejects underlying bids and stale cost reactions`, () => { + const f = fixture({ advanced: blackMarket, blackMarket }); nativeSource(f); + const pending = open(f), event = pending.pendingTreacheryDiscard!.batch.event; + reject(pending, f.spender, f.action); + reject(pending, f.owner, { type: 'passResponse' }); + reject(pending, f.nextBidder, blackMarket + ? { type: 'richeseBid', event: f.game.richeseAuction!.event, amount: 1 } + : { type: 'bid', amount: 1 }); + const game = prevented(claim(pending, f), f, true); + reject(game, f.holder, { type: 'semutaCommit', event }); + reject(game, f.holder, { type: 'semutaPass', event }); +}); + +void test('all four minimal policies resolve the genuine auction-inspection cost without revealing or advancing its lot', () => { + for (const difficulty of DIFFICULTIES) { + const f = fixture(), pending = open(f), event = pending.pendingTreacheryDiscard!.batch.event; + let game = pending; + for (let count = 0; game.pendingTreacheryDiscard?.batch.event === event && count < 32; count++) { + let proposal: { actor: string; action: Action } | undefined; + for (const seat of game.players) { + const view = viewGame(game, seat.id); + view.players.find(player => player.id === seat.id)!.bot = difficulty; + const before = structuredClone(view), action = botActions(view)[0]; + assert.deepEqual(view, before); + if (action) { proposal = { actor: seat.id, action }; break; } + } + assert.ok(proposal); + assert.ok(['semutaPass', 'semutaCommit', 'semutaSelect'].includes(proposal.action.type)); + game = act(game, proposal.actor, proposal.action); + } + assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event); + const claimed = !player(game, f.holder).hand.some(card => card.id === f.semuta); + prevented(game, f, claimed); + } +}); diff --git a/tests/semuta-ixian-auction-engine.test.ts b/tests/semuta-ixian-auction-engine.test.ts new file mode 100644 index 00000000..bcaed284 --- /dev/null +++ b/tests/semuta-ixian-auction-engine.test.ts @@ -0,0 +1,174 @@ +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 { + createSemutaIxianAuctionFixture as fixture, actSemutaIxianAuction as act, + passSemutaIxianAuction as pass, claimSemutaIxianAuction as claim, + finishSemutaIxianAuction as finish, semutaIxianAuctionPlayer as player, + semutaIxianAuctionInventory as inventory, type SemutaIxianAuctionFixture, +} from './fixture-semuta-ixian-auction'; + +const physical = (game: Game) => ({ turn: game.turn, phase: game.phase, order: game.order, + players: game.players.map(p => ({ id: p.id, spice: p.spice, reserves: p.reserves, tanks: p.tanks, + forces: p.forces, elites: p.elites, shipped: p.shipped, moved: p.moved, revived: p.revived })) }); +function reject(game: Game, actor: string, proposed: Action) { + const before = structuredClone(game); assert.throws(() => applyAction(game, actor, proposed)); assert.deepEqual(game, before); +} +function nativeSource(f: SemutaIxianAuctionFixture) { + assert.equal(f.game.advanced, f.advanced); assert.equal(f.setup.advanced, f.advanced); + assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.semutaPreview, true); + assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0)); + assert.ok(f.ixSetup); assert.equal(f.ixSetup.step.actor, f.owner); + assert.ok(f.ixSetup.before.ixSetupCards!.some(card => card.id === f.cost)); + assert.equal(player(f.afterSetup, f.owner).hand[0].kind, 'shield'); + assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost)); + assert.ok(player(f.afterSetup, f.observer).hand.some(card => card.id === f.observerKarama)); + 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.ok(player(f.game, f.holder).hand.some(card => card.id === f.semuta)); + assert.equal(f.beforePool.turn, 1); assert.equal(f.beforePool.response?.kind, 'ixAuction'); + assert.deepEqual(f.beforePool.ixAuction?.cards, []); assert.equal(f.beforePool.ixAuction?.count, f.poolCount); + assert.ok(f.beforePool.mobileStronghold?.location); assert.ok(!f.beforePool.auction); + assert.equal(f.game.response?.kind, f.kind); assert.equal(f.game.response?.owner, f.owner); + assert.equal(f.sourceActionCount, f.actions.length); assert.equal(f.originalInventory.length, 57); + assert.deepEqual(inventory(f.game), f.originalInventory); +} +function open(f: SemutaIxianAuctionFixture) { + const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard; assert.ok(pending); + assert.equal(pending.continuation.kind, 'karamaAuctionAdvantageDiscard'); + if (pending.continuation.kind !== 'karamaAuctionAdvantageDiscard') throw Error('Expected the exact printed auction-cost continuation'); + assert.equal(pending.continuation.use.kind, 'cancel'); + if (pending.continuation.use.kind !== 'cancel') throw Error('Expected the original cancellation'); + assert.equal(pending.continuation.use.response.kind, f.kind); + assert.equal(pending.continuation.use.response.owner, f.owner); + assert.deepEqual(pending.batch.entries.map(entry => entry.card.id), [f.cost]); + assert.deepEqual(pending.batch.entries.map(entry => entry.discardedBy), [f.spender]); + 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.deepEqual(game.deck, f.game.deck); assert.deepEqual(game.auction, f.game.auction); + assert.deepEqual(game.ixAuction, f.game.ixAuction); assert.deepEqual(game.pendingIxTechnology, f.game.pendingIxTechnology); + assert.equal(game.ixTechnologyTurn, f.game.ixTechnologyTurn); + assert.deepEqual(physical(game), physical(f.game)); assert.deepEqual(inventory(game), f.originalInventory); + assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true); + return game; +} +function physicalCost(game: Game, f: SemutaIxianAuctionFixture, claimed: boolean) { + assert.ok(!game.pendingTreacheryDiscard); assert.deepEqual(physical(game), physical(f.game)); + assert.deepEqual(inventory(game), f.originalInventory); + 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)); + assert.ok(player(game, f.observer).hand.some(card => card.id === f.observerKarama)); +} +function poolDenied(game: Game, f: SemutaIxianAuctionFixture, claimed: boolean) { + physicalCost(game, f, claimed); + assert.ok(!game.ixAuction); assert.ok(!game.ixAuctionKnown); + assert.notEqual(game.decision?.kind, 'ixAuction'); + const expected = f.game.deck.slice(0, f.poolCount).map(card => card.id); + assert.deepEqual(game.auction!.cards.map(card => card.id), expected); + assert.equal(game.auction!.index, 0); assert.equal(game.auction!.bid, 0); assert.equal(game.auction!.bidder, null); + assert.deepEqual(game.deck, f.game.deck.slice(f.poolCount)); + assert.equal(game.ixTechnologyTurn, f.game.ixTechnologyTurn); + const beforePeekDeck = structuredClone(game.deck), done = finish(game); + assert.deepEqual(done.deck, beforePeekDeck); assert.deepEqual(done.auction!.cards.map(card => card.id), expected); + assert.equal(done.auction!.peekKnown, true); + assert.equal(viewGame(done, f.spender).auction?.card?.id, expected[0]); + assert.equal(done.discard.filter(card => card.id === f.cost).length, Number(!claimed)); + assert.deepEqual(inventory(done), f.originalInventory); return done; +} +function technologyDenied(game: Game, f: SemutaIxianAuctionFixture, claimed: boolean) { + physicalCost(game, f, claimed); + assert.ok(f.technology && f.declaredCard && f.lotCard); + assert.equal(game.pendingIxTechnology, null); assert.equal(game.ixTechnologyTurn, game.turn); + assert.deepEqual(game.auction!.cards, f.game.auction!.cards); assert.equal(game.auction!.index, f.game.auction!.index); + assert.deepEqual(game.deck, f.game.deck); assert.deepEqual(game.ixAuctionKnown, f.game.ixAuctionKnown); + const expectedHand = player(f.game, f.owner).hand.filter(card => !(f.selectedSemuta && claimed && card.id === f.semuta)); + if (f.selectedSemuta && claimed) expectedHand.push(player(f.game, f.spender).hand.find(card => card.id === f.cost)!); + assert.deepEqual(player(game, f.owner).hand.map(card => card.id).sort(), expectedHand.map(card => card.id).sort()); + assert.equal(game.auction!.peekKnown, false); assert.equal(game.response?.kind, 'atreidesAuction'); + assert.equal(game.response?.owner, f.spender); + assert.equal(viewGame(game, f.spender).auction?.card, null); + reject(game, f.owner, f.technology.step.action); + const done = finish(game); + assert.equal(done.auction!.cards[done.auction!.index].id, f.lotCard); + assert.equal(viewGame(done, f.spender).auction?.card?.id, f.lotCard); + assert.equal(done.ixTechnologyTurn, done.turn); assert.equal(done.pendingIxTechnology, null); + assert.deepEqual(done.deck, f.game.deck); assert.deepEqual(inventory(done), f.originalInventory); + reject(done, f.owner, f.technology.step.action); return done; +} + +for (const advanced of [false, true]) for (const claimed of [false, true]) void test( + `${advanced ? 'Advanced' : 'Basic'} native Ixian undrawn-pool cost Semuta ${claimed ? 'claim' : 'pass'} draws only the original ordinary count after prevention`, () => { + const f = fixture({ advanced }); nativeSource(f); + const offered = open(f); + assert.deepEqual(offered.ixAuction!.cards, []); assert.equal(offered.ixAuction!.count, f.poolCount); + assert.ok(!offered.auction); assert.equal(offered.deck.length, f.game.deck.length); + const done = poolDenied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed); + assert.equal(done.decision, null); assert.equal(done.response, null); + }, +); + +for (const claimed of [false, true]) void test( + `Advanced native Technology outgoing-Shield cost Semuta ${claimed ? 'claim' : 'pass'} preserves the exact unseen lot and spent attempt`, () => { + const f = fixture({ kind: 'ixTechnology' }); nativeSource(f); + assert.ok(f.poolSelection); assert.equal(f.selectedSemuta, false); + assert.equal(f.technology!.step.action.card, f.declaredCard); + const offered = open(f); + const done = technologyDenied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed); + assert.ok(player(done, f.owner).hand.some(card => card.id === f.declaredCard)); + assert.equal(player(done, f.owner).hand.some(card => card.id === f.lotCard), false); + }, +); + +for (const claimed of [false, true]) void test( + `Advanced native Technology selected Semuta itself ${claimed ? 'claims the counter cost' : 'passes'} after its real paid acquisition`, () => { + const f = fixture({ kind: 'ixTechnology', holder: 'ixians' }); nativeSource(f); + assert.equal(f.holder, f.owner); assert.equal(f.selectedSemuta, true); + assert.equal(f.declaredCard, f.semuta); assert.equal(f.technology!.step.action.card, f.semuta); + const offered = open(f); + assert.equal(offered.pendingIxTechnology!.card, f.semuta); + assert.ok(player(offered, f.owner).hand.some(card => card.id === f.semuta)); + technologyDenied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed); + }, +); + +void test('native Ixian counter-cost windows reject stale, foreign and underlying replies without a premature draw or exchange', () => { + for (const kind of ['ixAuction', 'ixTechnology'] as const) { + const f = fixture({ advanced: true, kind }), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event; + reject(offered, f.holder, { type: 'semutaCommit', event: `${event}:stale` }); + reject(offered, f.spender, { type: 'semutaCommit', event }); + reject(offered, f.spender, f.action); reject(offered, f.owner, { type: 'passResponse' }); + reject(offered, f.owner, kind === 'ixTechnology' ? f.technology!.step.action + : { type: 'decision', card: f.game.deck[0].id, position: 'bottom' }); + const done = kind === 'ixAuction' ? poolDenied(pass(offered), f, false) : technologyDenied(pass(offered), f, false); + reject(done, f.holder, { type: 'semutaCommit', event }); + reject(done, f.spender, f.action); + } +}); + +void test('all four minimal profiles legally answer native pool and Technology costs including the outgoing Semuta singleton', () => { + const options = [{ advanced: false }, { advanced: true }, + { kind: 'ixTechnology' as const }, { kind: 'ixTechnology' as const, holder: 'ixians' as const }]; + for (const option of options) { + const f = fixture(option), 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 done = act(structuredClone(offered), f.holder, proposed); + const claimed = proposed.type === 'semutaCommit'; + if (done.pendingTreacheryDiscard?.reaction?.stage === 'select') { + const selecting = viewGame(done, 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); + done = act(done, f.holder, selected); + } else if (done.pendingTreacheryDiscard) done = pass(done); + if (f.kind === 'ixAuction') poolDenied(done, f, claimed); else technologyDenied(done, f, claimed); + } + } +}); diff --git a/tests/semuta-prevented-gift-reservation.test.ts b/tests/semuta-prevented-gift-reservation.test.ts new file mode 100644 index 00000000..053c10cd --- /dev/null +++ b/tests/semuta-prevented-gift-reservation.test.ts @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { + createSemutaRicheseGiftKaramaFixture, actSemutaRicheseGiftKarama as act, + claimSemutaRicheseGiftKarama as claim, passSemutaRicheseGiftKarama as pass, + finishSemutaRicheseGiftKarama as finish, advanceSemutaRicheseGiftKarama as advance, + semutaRicheseGiftKaramaClean as clean, semutaRicheseGiftKaramaInventory as inventory, + semutaRicheseGiftKaramaPlayer as player, +} from './fixture-semuta-richese-gift-karama'; + +const resources = (game: Game) => ({ + players: game.players.map(player => ({ id: player.id, spice: player.spice, + reserves: player.reserves, tanks: player.tanks, forces: player.forces, elites: player.elites, + moved: player.moved, shipped: player.shipped, revived: player.revived, ally: player.ally })), + order: game.order, active: game.active, ready: game.ready, + turn: game.turn, phase: game.phase, movementRemaining: game.movementRemaining, +}); +function reject(game: Game, actor: string, action: Action) { + const before = structuredClone(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before); +} + +/** Fork the inherited fixture's actual beforeGift snapshot. Its original paid + * Semuta1/Distrans1 acquisitions and native alliance are retained; only the + * final Distrans gift action is replaced by a real Semuta gift declaration. + * No played hand, resource, clock, parent or pending receipt is installed. */ +function source(options: { advanced?: boolean; parent?: 'quiet' | 'emperorIncome' } = {}) { + const inherited = createSemutaRicheseGiftKaramaFixture(options); + const prefixLength = inherited.actions.length - inherited.giftDeclaration.steps.length; + assert.equal(inherited.giftDeclaration.steps.length, 1); + assert.deepEqual(inherited.actions.slice(prefixLength), inherited.giftDeclaration.steps); + assert.deepEqual(inherited.giftDeclaration.before, inherited.beforeGift); + const actions = structuredClone(inherited.actions.slice(0, prefixLength)); + const beforeGift = structuredClone(inherited.beforeGift); + assert.ok(player(beforeGift, inherited.donor).hand.some(card => card.id === inherited.semuta)); + assert.ok(player(beforeGift, inherited.donor).hand.some(card => card.id === inherited.gift)); + assert.equal(player(beforeGift, inherited.donor).ally, inherited.recipient); + assert.equal(player(beforeGift, inherited.recipient).ally, inherited.donor); + assert.ok(viewGame(beforeGift, inherited.donor).richeseGift?.cards.some(card => card.id === inherited.semuta)); + const giftAction: Action = { type: 'richeseGift', card: inherited.semuta }; + const game = act(beforeGift, inherited.donor, giftAction, actions); + assert.deepEqual(actions.at(-1), { actor: inherited.donor, action: giftAction }); + assert.equal(actions.length, prefixLength + 1); + assert.equal(game.response?.kind, 'richeseGift'); + assert.equal(game.pendingRicheseGift?.intent.cardId, inherited.semuta); + assert.deepEqual(game.pendingRicheseGift!.resume, inherited.originalParent); + assert.deepEqual(inventory(game), inherited.originalInventory); + return { ...inherited, game, beforeGift, actions, sourceActionCount: actions.length, + giftAction, selectedGift: inherited.semuta, unusedDistrans: inherited.gift }; +} +type Source = ReturnType; + +function open(f: Source) { + const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard; + assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaRicheseGiftDiscard'); + assert.equal(pending.reaction?.stage, 'offer'); + assert.deepEqual(pending.batch.entries.map(entry => ({ id: entry.card.id, by: entry.discardedBy, public: entry.publicFace })), + [{ id: f.cost, by: f.spender, public: true }]); + assert.equal(game.pendingRicheseGift?.intent.cardId, f.semuta); + assert.deepEqual(game.pendingRicheseGift!.resume, f.originalParent); + assert.ok(player(game, f.donor).hand.some(card => card.id === f.semuta)); + assert.ok(player(game, f.donor).hand.some(card => card.id === f.unusedDistrans)); + assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true, + 'Only this already prevented gift reservation must release the actual Semuta role'); + assert.equal(game.richeseGiftBlocked?.cards.includes(f.semuta) ?? false, false); + assert.equal(game.discard.filter(card => card.id === f.cost).length, 1); + assert.deepEqual(resources(game), resources(f.game)); + assert.deepEqual(inventory(game), f.originalInventory); + return game; +} +function settled(state: Game, f: Source, claimed: boolean) { + const game = finish(state); + assert.ok(!game.pendingTreacheryDiscard); assert.ok(!game.pendingRicheseGift); + assert.deepEqual(resources(game), resources(f.game)); + assert.deepEqual(inventory(game), f.originalInventory); + assert.equal(player(game, f.recipient).hand.some(card => card.id === f.semuta), false); + assert.equal(player(game, f.donor).hand.filter(card => card.id === f.semuta).length, Number(!claimed)); + assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed)); + assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false); + assert.equal(player(game, f.donor).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.donor).hand.filter(card => card.id === f.unusedDistrans).length, 1); + assert.equal(player(game, f.recipient).hand.some(card => card.id === f.unusedDistrans), false); + assert.deepEqual(game.richeseGiftBlocked, { turn: f.game.turn, phase: f.game.phase, cards: [f.semuta] }); + assert.deepEqual(game.response, f.originalParent.response); + assert.deepEqual(game.decision, f.originalParent.decision); + assert.deepEqual(game.phaseOpening, f.originalParent.phaseOpening); + assert.deepEqual(game.pendingKarama, f.originalParent.pendingKarama); + assert.ok(player(game, f.observer).hand.some(card => card.id === f.parentCounter)); + reject(game, f.donor, f.giftAction); + return game; +} + +for (const advanced of [false, true]) for (const parent of ['quiet', 'emperorIncome'] as const) for (const claimed of [false, true]) { + void test(`${advanced ? 'Advanced' : 'Basic'} prevented Semuta gift ${claimed ? 'spends that reserved Semuta to claim' : 'passes'} its cost with ${parent} parent`, () => { + const f = source({ advanced, parent }); + assert.equal(f.semutaPurchase.price, 1); assert.equal(f.giftPurchase.price, 1); + assert.ok(f.semutaPurchase.steps.some(step => step.actor === f.donor && step.action.type === 'richeseBid' && step.action.amount === 1)); + assert.ok(f.giftPurchase.steps.some(step => step.actor === f.donor && step.action.type === 'richeseBid' && step.action.amount === 1)); + assert.equal(f.holder, f.donor); + const pending = open(f), event = pending.pendingTreacheryDiscard!.batch.event; + const game = settled(claimed ? claim(pending, f.holder) : pass(pending), f, claimed); + reject(game, f.holder, { type: 'semutaCommit', event }); + reject(game, f.holder, { type: 'semutaPass', event }); + if (parent === 'emperorIncome') { + assert.equal(game.response?.kind, 'emperorIncome'); + const before = player(game, f.recipient).spice; + const remaining = advance(game, clean); + assert.equal(player(remaining, f.recipient).spice, before + 1, + 'The one original cache-purchase income resumes once after gift prevention and Semuta settlement'); + assert.ok(!remaining.pendingRicheseGift); assert.ok(!remaining.pendingTreacheryDiscard); + assert.deepEqual(remaining.richeseGiftBlocked, game.richeseGiftBlocked); + assert.equal(player(remaining, f.recipient).hand.some(card => card.id === f.semuta), false); + assert.ok(player(remaining, f.donor).hand.some(card => card.id === f.unusedDistrans)); + assert.deepEqual(inventory(remaining), f.originalInventory); + } + }); +} + +void test('the prevented selected-Semuta reservation permits only its actual cost reaction and rejects stale replay', () => { + const f = source({ advanced: true, parent: 'emperorIncome' }); + const pending = open(f), event = pending.pendingTreacheryDiscard!.batch.event; + reject(pending, f.spender, f.action); + reject(pending, f.donor, f.giftAction); + reject(pending, f.recipient, { type: 'passResponse' }); + reject(pending, f.holder, { type: 'semutaCommit', event: `${event}:stale` }); + const game = settled(claim(pending, f.holder), f, true); + reject(game, f.holder, { type: 'semutaCommit', event }); + assert.deepEqual(game.richeseGiftBlocked!.cards, [f.semuta]); + assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1); + assert.equal(player(game, f.donor).hand.filter(card => card.id === f.cost).length, 1); +});