From 6e1b95d2fc939c06269a9521c176409a8fa1a418 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:46:28 +1100 Subject: [PATCH] Allow bounded Kull cancellation continuations --- docs/CHOAM_KULL_DESIGN.md | 31 +++- docs/CURRENT_STATUS.md | 12 ++ docs/RULE_DECISIONS.md | 8 + game/engine.ts | 82 +++++++++- game/reference.ts | 2 +- tests/kull-whole-cancel-owner-guard.test.ts | 163 +++++++++++++++++++ tests/kull-whole-choam-cancel-cost.test.ts | 118 ++++++++++++++ tests/kull-whole-common-cancel.test.ts | 92 +++++++++++ tests/kull-whole-guild-arrival-guard.test.ts | 68 ++++++++ 9 files changed, 569 insertions(+), 7 deletions(-) create mode 100644 tests/kull-whole-cancel-owner-guard.test.ts create mode 100644 tests/kull-whole-choam-cancel-cost.test.ts create mode 100644 tests/kull-whole-common-cancel.test.ts create mode 100644 tests/kull-whole-guild-arrival-guard.test.ts diff --git a/docs/CHOAM_KULL_DESIGN.md b/docs/CHOAM_KULL_DESIGN.md index d09cb778..33d84250 100644 --- a/docs/CHOAM_KULL_DESIGN.md +++ b/docs/CHOAM_KULL_DESIGN.md @@ -1,5 +1,31 @@ # Kull Wahad: source limits and Karama interruption design +## Basic whole-answer cancellation continuations — 29 October 2026 + +**Prototyped / Partial.** In the supported Basic printed-Kull profile, a +player with a binding whole-turn answer may use a physical Karama to cancel +Voice, Prescience, Basic Ixian cyborg strength or clean Guild shipment income +when the detached quote retains a legal completion. The original and a +distinct counter use the same known-parent quote; Kull success keeps the +original response open. An answer naming that exact printed Voice or +Prescience cancellation still governs the original window. A payment-only +CHOAM answer can be quoted across the reopened battle response without +choosing another player's response or battle plan. + +The combat quote stops before any later foreign response or decision. A Guild +income quote admits a plain pending shipment entry only when no Ecaz or +Moritani can react; an accompanying advisor decision, queued automatic +advisor flip or other arrival remains guarded. Other cancellation parents, pending successor decisions, broader +profiles and completion witnesses are still conservative fences, not printed +restrictions. The existing online ordering is provisional. Genuine game tests +select unopened cards; two exact-window cases recycle the same physical +Truthtrance in a controlled fixture. The automatic-advisor-queue fence was +reviewed against its native continuation but has no direct gameplay fixture yet. +[Voice and Prescience](../tests/kull-whole-cancel-owner-guard.test.ts), +[Guild and Ixian strength](../tests/kull-whole-common-cancel.test.ts), +[CHOAM commitments](../tests/kull-whole-choam-cancel-cost.test.ts), +[arrival decision guard](../tests/kull-whole-guild-arrival-guard.test.ts). + ## Basic whole-turn answers with Kull — 29 October 2026 **Prototyped / Partial.** Combined whole-turn Truthtrance questions are @@ -12,8 +38,9 @@ and auction preflight uses a detached deterministic quote: an unknown auction face is opaque, future lots remain in their original custody, and foreign choices and draws are not executed to establish a witness. -The quote is deliberately conservative: cancellation parents and a purchase -with an outstanding positive phase-wide bid answer are fenced while the +The quote was initially conservative about cancellation parents; the later +section above opens several of them. A purchase +with an outstanding positive phase-wide bid answer is fenced while the actor has a whole-turn answer, as are unproved counter continuations. A completion depending on recovering the spent Karama or on Semuta at a later discard may also be rejected. These are prototype limits, not printed diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 3ea22059..8872dcfa 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,18 @@ open mode gates. ## Current checkpoint and work +**Basic Kull cancellations with whole-turn answers — 29 October. Prototyped / Partial.** +Voice, Prescience, Basic Ixian cyborg strength and clean Guild income +cancellations can now retain a binding whole-turn answer through the supported +Kull continuation. Exact Voice/Prescience cancellation answers still constrain +the printed card; CHOAM's own payment-only answer can survive the reopened +response. A separate advisor decision and unproved later effects stay guarded. +Eleven related files pass 47 cases; typecheck, lint and the production build +pass. The automatic-advisor queue guard has no direct gameplay fixture yet. +Broader cancellations, profiles and complete rules +remain unfinished. +[Scope](CHOAM_KULL_DESIGN.md#basic-whole-answer-cancellation-continuations--29-october-2026). + **Basic whole-turn Truthtrance with printed Kull — 29 October. Prototyped / Partial.** Combined turn-action answers now work in the supported bare Basic Kull profile, including Richese's Semuta. The interrupted answer survives supported Kull and diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 5bc6b0fd..eb7b0908 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,13 @@ # Rule decision index +## Basic whole-answer cancellation continuations — 29 October 2026 + +The supported Basic printed-Kull profile now quotes selected Voice, +Prescience, Ixian strength and clean Guild income cancellations alongside +whole-turn answers. Other parents and independent pending decisions remain +fenced; this is a provisional software boundary, not a printed rule. +[Scope](CHOAM_KULL_DESIGN.md#basic-whole-answer-cancellation-continuations--29-october-2026). + ## Basic whole-turn answers with Kull — 29 October 2026 The supported bare Basic profile admits combined whole-turn answers through diff --git a/game/engine.ts b/game/engine.ts index f04623a4..f31f801f 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -19253,18 +19253,50 @@ function kullCostPromisesAllow(g: Game, p: Player, card: Card, fuelSource: 'prin projected.decision = null; projected.pendingKarama = null; let wholeSource = projected; + if (wholePromises.length && g.pendingKull?.intent.kind === 'ordinary' && + g.pendingKull.intent.use.kind === 'cancel') { + // Kull success leaves the original response open; prevention resumes the + // original cancellation from that same response. Keep it for either quote. + wholeSource = structuredClone(projected); + wholeSource.response = structuredClone(source.response); + wholeSource.decision = structuredClone(source.decision); + wholeSource.phaseOpening = structuredClone(source.phaseOpening); + } if (!success && wholePromises.length && g.pendingKull) { if (g.pendingKull.intent.kind !== 'ordinary') return false; - const original = getPlayer(projected, g.pendingKull.owner); + const original = getPlayer(wholeSource, g.pendingKull.owner); const attempted = original.hand.find(row => row.id === g.pendingKull!.card); if (!attempted) return false; - const resumed = projectKullOrdinaryOutcome(projected, original, attempted, g.pendingKull.intent.use); + const resumed = projectKullOrdinaryOutcome(wholeSource, original, attempted, g.pendingKull.intent.use); if (!resumed) return false; wholeSource = resumed; } + let wholeCompletion = !wholePromises.length || !!turnActionCompletion(wholeSource, p.id); + if (!wholeCompletion && success && wholeSource.response && + (wholeSource.response.kind === 'voice' || wholeSource.response.kind === 'prescience') && + !wholeSource.decision && !wholeSource.phaseOpening && !battlePromises.length && !shipmentPromises.length && + !(['travelPromises','movementPromises','movementExpressionPromises','bidPromises','phaseBidPromises', + 'revivalPromises','leaderRevivalPromises','revivalExpressionPromises','bribePromises', + 'bribeExpressionPromises'] as const).some(key => wholeSource[key]?.some(row => + row.player === p.id && !row.fulfilled && !row.released)) && + wholePromises.every(promise => { + let found = false; + for (const branch of turnActionRule(() => turnActionBranches(promise))) { + found = true; + if (branch.some(leaf => leaf.kind !== 'payment')) return false; + } + return found; + })) { + // The reopened Voice/Prescience response cannot change the payer's + // wallet or recipient. Quote a payment-only answer after that response, + // without choosing either player's response or a later battle plan. + const afterResponse = structuredClone(wholeSource); + afterResponse.response = null; + wholeCompletion = !!turnActionCompletion(afterResponse, p.id); + } if ((battlePromises.length && !findReachableBattlePlan(projected, owner, { promises: battlePromises })) || (shipmentPromises.length && !findShipmentCompletion(projected, owner, shipmentPromises)) || - (wholePromises.length && !turnActionCompletion(wholeSource, p.id))) + !wholeCompletion) return false; } return true; @@ -19497,10 +19529,52 @@ function resumeKullAttempt(g: Game, prevented: boolean): void { spendKarama(g, p, p.hand.find(card => card.id === frame.card)!, frame.intent.use, true); } } -/** Quote only known shipment-price and ordinary-auction effects without +/** Quote known shipment-price, ordinary-auction and bounded cancellation effects without * executing a foreign response, drawing a card or exposing a hidden lot. * Other parent effects need their own deterministic whole-answer quotation. */ function projectKullOrdinaryOutcome(g: Game, player: Player, card: Card, use: KaramaUse): Game | null { + if (use.kind === 'cancel') { + const response = use.response; + // A separately owned arrival/phase decision remains at the table after + // this response; do not erase it to manufacture a completion witness. + if (!g.response || g.decision || g.phaseOpening || + JSON.stringify(g.response) !== JSON.stringify(response)) return null; + const battleWindow = karamaBattleCancelOpportunity(g, player.id); + const trial = structuredClone(g), owner = getPlayer(trial, player.id); + if (!owner.hand.some(held => held.id === card.id)) return null; + owner.hand = owner.hand.filter(held => held.id !== card.id); + if (battleWindow && card.kind === 'special' && card.effect === 'karama') + recordNativeTurnActionWindowCard(trial, player.id, 'karamaBattleCancel', battleWindow.source); + if (response.kind === 'voice' || response.kind === 'prescience' || response.kind === 'eliteStrength') { + const quote = currentCombatResponseQuote(trial, {kind:'response', + response:{kind:response.kind, owner:response.owner}, canceled:true}); + // A later power/choice belongs to its original owner and cannot be + // assumed as part of this deterministic cancellation quote. + if (quote.response || quote.decision) return null; + commitCombatResponseQuote(trial, quote); + } else { + // A plain queued shipment entry has no remaining effect without Ecaz + // or Moritani. Any other queued entry may own a separate decision. + const arrivals = g.pendingArrivalReaction ?? []; + const inertArrivals = !arrivals.length || (!byFaction(g, 'ecaz') && !byFaction(g, 'moritani') && + arrivals.every(entry => entry.cause === 'shipment' && entry.resume === 'none' && + entry.turn === g.turn && entry.phase === g.phase && + Object.keys(entry).every(key => ['entrant','territory','sector','amount','elite','cause','resume','turn','phase'].includes(key)))); + if (!semutaEmperorShareKaramaResponse(g, response) && + !(response.kind === 'guildIncome' && response.source === undefined && + semutaIncomeKaramaResponse(g, response) && !g.pendingArrivalOverlap && + inertArrivals && !g.pendingSpiritualAdvisor && !g.pendingAutomaticAdvisorFlips?.length)) return null; + } + if (battleWindow) { + const original = liveTurnActionPromises(trial.turnActionPromises ?? [], player.id, trial.turn); + trial.turnActionPromises = turnActionRule(() => closeTurnActionWindowCardPlay( + trial.turnActionPromises ?? [], player.id, trial.turn, 'karamaBattleCancel', battleWindow.source)); + if (original.some(row => trial.turnActionPromises?.find(next => next.event === row.event)?.released)) return null; + } + trial.truthtrance = null; trial.response = null; trial.decision = null; + trial.pendingKarama = null; trial.phaseOpening = null; trial.pendingKull = null; + return trial; + } if (use.kind !== 'shipment' && use.kind !== 'purchase' && use.kind !== 'auctionPayment') return null; const window = windowCardOpportunity(g, use.kind === 'shipment' ? 'karamaShipment' : 'karamaAuction'); const lot = use.kind === 'shipment' ? null : bidOpportunity(g); diff --git a/game/reference.ts b/game/reference.ts index 0bb6ee66..83eaee47 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3316,7 +3316,7 @@ export const RULE_TOPICS: RuleTopic[] = [ steps: [ 'In a quiet Basic Storm, Spice Blow, Charity, Bidding, Revival or Shipment and Movement window, ask whether a player will use a named native leader in their first later battle this turn, if one occurs. Storm must have a pending movement after the dials; Spice Blow must be before the draw. The answer waits for an actual battle; if none occurs, it expires. This separate provisional question currently supports classic factions without optional modules and uses only currently available leaders for its answer.', 'Choose Combine printed card plays, native battle powers and this turn’s other actions. Full AND/OR groups share sixteen logical leaves and four grouping levels, with optional current facts. This is a provisional composition of original-window card plays, native power declarations, printed Hajr/Ghola use, current battle decisions, final Nexus membership, current-lot bids, ordinary Revival, funded payments and owned-stage travel; wider action families and optional combinations remain unfinished.', - 'The supported bare Basic printed-Kull profile also permits these questions, including with Richese and Semuta. The answer remains bound while Kull or its counter is resolving; some Karama uses remain guarded until their effects can be quoted without assuming hidden or foreign outcomes.', + 'The supported bare Basic printed-Kull profile also permits these questions, including with Richese and Semuta. The answer remains bound while Kull or its counter is resolving. Voice, Prescience, Ixian cyborg strength and clean Guild income cancellations can use a proven continuation; other Karama uses remain guarded until their effects can be quoted without assuming hidden or foreign outcomes.', 'A final-alliance clause names another current participant during this original quiet native Nexus. Existing invitations and reciprocal formation remain authoritative. Offers, temporary membership and your own Ready do not settle the clause; final actual membership is fixed only when the Nexus closes.', 'Whole No can permit a temporary paid pair and later require a break. Own Ready must preserve a completion after final membership is fixed; remaining payment or physical-action alternatives keep their later deadlines. Definite answers, including constants, uniformly reopen own Nexus readiness; later ordinary payments require confirmation again.', 'Missing foreign consent can offer I don’t know and save or change the question; it never invents a definite Yes. Unknown requires an otherwise funded native completion, so a known unaffordable branch stays No. Foreign withdrawal can release impossibility without vetoing the other player’s rights.', diff --git a/tests/kull-whole-cancel-owner-guard.test.ts b/tests/kull-whole-cancel-owner-guard.test.ts new file mode 100644 index 00000000..ec8bb9ab --- /dev/null +++ b/tests/kull-whole-cancel-owner-guard.test.ts @@ -0,0 +1,163 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine'; +import { TERRITORIES } from '../game/board'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { windowCardOpportunity } from '../game/window-card-play'; +import { act, advance, clean, inventory, player } from './fixture-kull-richese-semuta'; +import { nextSemutaRicheseCacheKarama } from './fixture-semuta-richese-cache-karama'; + +/** A genuine five-seat Basic game. Select only physical unopened starting + * cards and a safe unopened Spice card; the Truth answer, cache purchase, + * shipment, battle, parent power, and Kull actions are all native actions. */ +function source(power:'voice'|'prescience', answer:'yes'|'no') { + const owner=power==='voice'?'beneGesserit':'atreides'; + let game=createGame('KULLWHOLECANCEL',newPlayer('choam','CHOAM','choam'),false,['choam','ix']); + for (const id of ['richese','emperor',owner,'harkonnen'] as const) joinGame(game,newPlayer(id,id,id)); + game=act(game,'choam',{type:'moduleOption',module:'kullPreview',enabled:true}); + for (const seat of game.players) game=act(game,seat.id,{type:'ready'}); + game=act(game,'choam',{type:'start'}); + assert.equal(game.status,'setup'); assert.ok(game.kullPreview && game.semutaPreview); + const originalCards=inventory(game), selected:Game['deck']=[]; + for (const kind of ['kull','filler','karama','filler','karama','truth'] as const) { + const index=game.deck.findIndex(card=>kind==='kull'?card.id==='ix-kull-wahad': + kind==='truth'?card.effect==='truthtrance':kind==='karama'?card.effect==='karama': + card.kind==='worthless' && card.id!=='ix-kull-wahad'); + assert.ok(index>=0); selected.push(game.deck.splice(index,1)[0]); + } + game.deck.unshift(...selected); + game=advance(game,state=>state.status==='playing'); + const [kull,,karama,,counter,truth]=selected.map(card=>card.id); + for (const [id,card] of [['choam',kull],['emperor',karama],['harkonnen',counter],['harkonnen',truth]] as const) + assert.ok(player(game,id).hand.some(row=>row.id===card)); + const safe=game.spiceDeck.findIndex(card=>'territory' in card && !card.discovery); assert.ok(safe>=0); + game.spiceDeck.unshift(game.spiceDeck.splice(safe,1)[0]); + game=advance(game,state=>state.phase===2 && clean(state) && state.choamCharity?.turn===state.turn); + game=act(game,'harkonnen',{type:'card',card:truth}); + while (game.truthtrance?.stage==='priority') { + const seat=game.players.find(row=>!game.truthtrance!.passed.includes(row.id)); assert.ok(seat); + game=act(game,seat.id,{type:'truthPass'}); + } + game=act(game,'harkonnen',{type:'truthAsk',question:{kind:'turnActions',target:'emperor',turn:game.turn, + mixed:{kind:'action',action:{kind:'payment',recipient:'richese',minimum:1}}}}); + assert.ok(viewGame(game,'emperor').truthTurnActionAnswers?.includes(answer)); + game=act(game,'emperor',{type:'truthAnswer',answer}); + const commitment=game.turnActionPromises?.at(-1); assert.ok(commitment); + game=advance(game,state=>player(state,'richese').hand.some(card=>card.id===SEMUTA_DRUG_ID) && clean(state)); + let target:{territory:string;sector:number}|null=null; + for (let count=0;count<1000 && game.phase!==6;count++) { + if (game.phase===5 && clean(game) && game.active) { + const actor=game.active, seat=player(game,actor); + if ((actor==='emperor'||actor===owner) && !seat.shipped) { + if (!target) { + const territory=TERRITORIES.find(row=>row.type==='stronghold' && row.sectors.some(s=>s!==game.storm) && + game.players.every(p=>!Object.keys(p.forces).some(key=>key.startsWith(`${row.id}:`)))); + assert.ok(territory); + target={territory:territory.id,sector:territory.sectors.find(s=>s!==game.storm)!}; + } + game=act(game,actor,{type:'ship',territory:target.territory,sector:target.sector,amount:1,elite:0,allyPayment:0}); + } else game=act(game,actor,{type:'endMovement'}); + continue; + } + const next=nextSemutaRicheseCacheKarama(game); assert.ok(next); + game=act(game,next.actor,next.action); + } + assert.equal(game.phase,6); assert.ok(target); + assert.equal(player(game,'emperor').forces[`${target.territory}:${target.sector}`],1); + assert.equal(player(game,owner).forces[`${target.territory}:${target.sector}`],1); + for (let count=0;count<30 && !game.battle;count++) { + const view=game.active?viewGame(game,game.active):null; + const choice=view?.battleChoices?.find(row=>row.territory===target!.territory && + (row.chooser??row.attacker)===game.active && [row.attacker,row.defender].includes(game.active!)); + if (choice) { + game=act(game,game.active!,{type:'chooseBattle',territory:target.territory, + target:game.active==='emperor'?owner:'emperor'}); + break; + } + const next=nextSemutaRicheseCacheKarama(game); assert.ok(next); + game=act(game,next.actor,next.action); + } + assert.ok(game.battle); + for (let count=0;count<30 && game.battle?.preparation?.kind!==power;count++) { + const next=nextSemutaRicheseCacheKarama(game); assert.ok(next); + game=act(game,next.actor,next.action); + } + assert.equal(game.battle?.preparation?.kind,power); + game=act(game,owner,power==='voice'?{type:'voice',kind:'shield',must:false}:{type:'prescience',field:'dial'}); + assert.equal(game.response?.kind,power); + assert.ok(game.turnActionPromises?.some(row=>row.event===commitment.event && !row.released)); + assert.deepEqual(inventory(game),originalCards); + return {game,kull,karama,counter,truth,event:commitment.event,owner,originalCards}; +} +function passDiscard(game:Game):Game { + const pending=game.pendingTreacheryDiscard; assert.ok(pending?.reaction?.stage==='offer'); + const event=pending.batch.event; + for (let count=0;game.pendingTreacheryDiscard?.batch.event===event && count!reaction.passed.includes(row.id)); assert.ok(seat); + game=act(game,seat.id,{type:'semutaPass',event}); + } + assert.notEqual(game.pendingTreacheryDiscard?.batch.event,event); + return game; +} + +for (const power of ['voice','prescience'] as const) for (const outcome of ['decline','success','counter'] as const) + void test(`Basic whole payment Yes survives ${power} cancellation and Kull ${outcome}`,()=>{ + const f=source(power,'yes'); let game=f.game; + assert.ok(viewGame(game,'emperor').responseControls?.cancelCards.includes(f.karama)); + game=act(game,'emperor',{type:'card',mode:'cancel',card:f.karama}); + const reaction=viewGame(game,'choam').kullReaction; assert.ok(reaction); + game=outcome==='decline'?act(game,'choam',{type:'kullDecision',event:reaction.event,decline:true}): + act(game,'choam',{type:'kullDecision',event:reaction.event,source:'printed',card:f.kull}); + if (outcome==='counter') game=act(game,'harkonnen',{type:'card',mode:'cancel',card:f.counter}); + if (outcome==='success') game=advance(game,state=>!!state.pendingTreacheryDiscard); + while (game.pendingTreacheryDiscard) game=passDiscard(game); + assert.ok(game.turnActionPromises?.some(row=>row.event===f.event && !row.released)); + if (outcome==='success') assert.ok(player(game,'emperor').hand.some(card=>card.id===f.karama)); + else assert.ok(!player(game,'emperor').hand.some(card=>card.id===f.karama)); + assert.deepEqual(inventory(game),f.originalCards); + }); + +void test('Basic whole payment No remains binding when a Voice cancellation resolves',()=>{ + const f=source('voice','no'); let game=f.game; + assert.ok(viewGame(game,'emperor').responseControls?.cancelCards.includes(f.karama)); + game=act(game,'emperor',{type:'card',mode:'cancel',card:f.karama}); + const reaction=viewGame(game,'choam').kullReaction; assert.ok(reaction); + game=act(game,'choam',{type:'kullDecision',event:reaction.event,decline:true}); + while (game.pendingTreacheryDiscard) game=passDiscard(game); + assert.ok(game.turnActionPromises?.some(row=>row.event===f.event && !row.released)); + assert.deepEqual(inventory(game),f.originalCards); +}); + +for (const answer of ['yes','no'] as const) + void test(`the exact Voice cancellation whole-answer ${answer} governs the printed Karama through Kull`,()=>{ + const f=source('voice','no'); let game=structuredClone(f.game); + // Recycle the fixture's original physical Truthtrance to ask at the actual + // response; the preceding payment commitment and native battle stay real. + const index=game.discard.findIndex(card=>card.id===f.truth); assert.ok(index>=0); + player(game,'harkonnen').hand.push(game.discard.splice(index,1)[0]); + const window=windowCardOpportunity(game,'karamaBattleCancel'); assert.ok(window); + game=act(game,'harkonnen',{type:'card',card:f.truth}); + while (game.truthtrance?.stage==='priority') { + const seat=game.players.find(row=>!game.truthtrance!.passed.includes(row.id)); assert.ok(seat); + game=act(game,seat.id,{type:'truthPass'}); + } + game=act(game,'harkonnen',{type:'truthAsk',question:{kind:'turnActions',target:'emperor',turn:game.turn, + mixed:{kind:'action',action:{kind:'windowCardPlay',effect:'karamaBattleCancel',source:window.source}}}}); + assert.ok(viewGame(game,'emperor').truthTurnActionAnswers?.includes(answer)); + game=act(game,'emperor',{type:'truthAnswer',answer}); + const promise=game.turnActionPromises?.at(-1); assert.ok(promise); + while (game.pendingTreacheryDiscard) game=passDiscard(game); + if (answer==='no') { + assert.ok(!viewGame(game,'emperor').responseControls?.cancelCards.includes(f.karama)); + assert.throws(()=>act(game,'emperor',{type:'card',mode:'cancel',card:f.karama})); + } else { + assert.ok(viewGame(game,'emperor').responseControls?.cancelCards.includes(f.karama)); + game=act(game,'emperor',{type:'card',mode:'cancel',card:f.karama}); + const reaction=viewGame(game,'choam').kullReaction; assert.ok(reaction); + game=act(game,'choam',{type:'kullDecision',event:reaction.event,decline:true}); + while (game.pendingTreacheryDiscard) game=passDiscard(game); + assert.equal(game.turnActionPromises?.find(row=>row.event===promise.event)?.fulfilled,true); + } + assert.deepEqual(inventory(game),f.originalCards); + }); diff --git a/tests/kull-whole-choam-cancel-cost.test.ts b/tests/kull-whole-choam-cancel-cost.test.ts new file mode 100644 index 00000000..b0e64408 --- /dev/null +++ b/tests/kull-whole-choam-cancel-cost.test.ts @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine'; +import { TERRITORIES } from '../game/board'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { act, advance, clean, inventory, player } from './fixture-kull-richese-semuta'; +import { nextSemutaRicheseCacheKarama } from './fixture-semuta-richese-cache-karama'; + +/** Native five-seat Basic setup, actual Truthtrance answer, cache purchase, + * shipments and battle. Only unopened starting cards and one safe Spice + * position are selected; no wallet, force, response or promise is staged. */ +function source(power:'voice'|'prescience') { + const owner=power==='voice'?'beneGesserit':'atreides'; + let game=createGame('KULLWHOLECHOAM',newPlayer('choam','CHOAM','choam'),false,['choam','ix']); + for (const id of ['richese','emperor',owner,'harkonnen'] as const) joinGame(game,newPlayer(id,id,id)); + game=act(game,'choam',{type:'moduleOption',module:'kullPreview',enabled:true}); + for (const seat of game.players) game=act(game,seat.id,{type:'ready'}); + game=act(game,'choam',{type:'start'}); + assert.equal(game.status,'setup'); assert.ok(game.kullPreview && game.semutaPreview); + const originalCards=inventory(game), selected:Game['deck']=[]; + for (const kind of ['kull','filler','karama','filler','karama','truth'] as const) { + const index=game.deck.findIndex(card=>kind==='kull'?card.id==='ix-kull-wahad': + kind==='truth'?card.effect==='truthtrance':kind==='karama'?card.effect==='karama': + card.kind==='worthless' && card.id!=='ix-kull-wahad'); + assert.ok(index>=0); selected.push(game.deck.splice(index,1)[0]); + } + game.deck.unshift(...selected); + game=advance(game,state=>state.status==='playing'); + const [kull,,karama,,counter,truth]=selected.map(card=>card.id); + for (const [id,card] of [['choam',kull],['emperor',karama],['harkonnen',counter],['harkonnen',truth]] as const) + assert.ok(player(game,id).hand.some(row=>row.id===card)); + const safe=game.spiceDeck.findIndex(card=>'territory' in card && !card.discovery); assert.ok(safe>=0); + game.spiceDeck.unshift(game.spiceDeck.splice(safe,1)[0]); + game=advance(game,state=>state.phase===2 && clean(state) && state.choamCharity?.turn===state.turn); + game=act(game,'harkonnen',{type:'card',card:truth}); + while (game.truthtrance?.stage==='priority') { + const seat=game.players.find(row=>!game.truthtrance!.passed.includes(row.id)); assert.ok(seat); + game=act(game,seat.id,{type:'truthPass'}); + } + game=act(game,'harkonnen',{type:'truthAsk',question:{kind:'turnActions',target:'choam',turn:game.turn, + mixed:{kind:'action',action:{kind:'payment',recipient:'richese',minimum:1}}}}); + assert.ok(viewGame(game,'choam').truthTurnActionAnswers?.includes('yes')); + game=act(game,'choam',{type:'truthAnswer',answer:'yes'}); + const commitment=game.turnActionPromises?.at(-1); assert.ok(commitment); + game=advance(game,state=>player(state,'richese').hand.some(card=>card.id===SEMUTA_DRUG_ID) && clean(state)); + let target:{territory:string;sector:number}|null=null; + for (let count=0;count<1000 && game.phase!==6;count++) { + if (game.phase===5 && clean(game) && game.active) { + const actor=game.active, seat=player(game,actor); + if ((actor==='emperor'||actor===owner) && !seat.shipped) { + if (!target) { + const territory=TERRITORIES.find(row=>row.type==='stronghold' && row.sectors.some(s=>s!==game.storm) && + game.players.every(p=>!Object.keys(p.forces).some(key=>key.startsWith(`${row.id}:`)))); + assert.ok(territory); + target={territory:territory.id,sector:territory.sectors.find(s=>s!==game.storm)!}; + } + game=act(game,actor,{type:'ship',territory:target.territory,sector:target.sector,amount:1,elite:0,allyPayment:0}); + } else game=act(game,actor,{type:'endMovement'}); + continue; + } + const next=nextSemutaRicheseCacheKarama(game); assert.ok(next); + game=act(game,next.actor,next.action); + } + assert.equal(game.phase,6); assert.ok(target); + for (let count=0;count<30 && !game.battle;count++) { + const view=game.active?viewGame(game,game.active):null; + const choice=view?.battleChoices?.find(row=>row.territory===target!.territory && + (row.chooser??row.attacker)===game.active && [row.attacker,row.defender].includes(game.active!)); + if (choice) { + game=act(game,game.active!,{type:'chooseBattle',territory:target.territory, + target:game.active==='emperor'?owner:'emperor'}); + break; + } + const next=nextSemutaRicheseCacheKarama(game); assert.ok(next); + game=act(game,next.actor,next.action); + } + assert.ok(game.battle); + for (let count=0;count<30 && game.battle?.preparation?.kind!==power;count++) { + const next=nextSemutaRicheseCacheKarama(game); assert.ok(next); + game=act(game,next.actor,next.action); + } + assert.equal(game.battle?.preparation?.kind,power); + game=act(game,owner,power==='voice'?{type:'voice',kind:'shield',must:false}:{type:'prescience',field:'dial'}); + assert.equal(game.response?.kind,power); + const row=game.turnActionPromises?.find(promise=>promise.event===commitment.event); assert.ok(row); + assert.ok(!row.fulfilled && !row.released); + assert.deepEqual(inventory(game),originalCards); + return {game,kull,karama,counter,event:commitment.event,originalCards}; +} +function passDiscard(game:Game):Game { + const pending=game.pendingTreacheryDiscard; assert.ok(pending?.reaction?.stage==='offer'); + const event=pending.batch.event; + for (let count=0;game.pendingTreacheryDiscard?.batch.event===event && count!reaction.passed.includes(row.id)); assert.ok(seat); + game=act(game,seat.id,{type:'semutaPass',event}); + } + assert.notEqual(game.pendingTreacheryDiscard?.batch.event,event); + return game; +} + +for (const power of ['voice','prescience'] as const) for (const countered of [false,true]) + void test(`CHOAM's live whole answer survives Kull ${countered?'counter':'success'} against ${power} cancellation`,()=>{ + const f=source(power); let game=f.game; + assert.ok(viewGame(game,'emperor').responseControls?.cancelCards.includes(f.karama)); + game=act(game,'emperor',{type:'card',mode:'cancel',card:f.karama}); + const reaction=viewGame(game,'choam').kullReaction; assert.ok(reaction); + assert.ok(reaction.plays.some(play=>play.source==='printed' && play.card.id===f.kull)); + game=act(game,'choam',{type:'kullDecision',event:reaction.event,source:'printed',card:f.kull}); + if (countered) game=act(game,'harkonnen',{type:'card',mode:'cancel',card:f.counter}); + else game=advance(game,state=>!!state.pendingTreacheryDiscard); + while (game.pendingTreacheryDiscard) game=passDiscard(game); + const promise=game.turnActionPromises?.find(row=>row.event===f.event); assert.ok(promise); + assert.ok(!promise.released && !promise.fulfilled); + assert.equal(player(game,'emperor').hand.some(card=>card.id===f.karama),!countered); + if (!countered) assert.equal(game.response?.kind,power); + assert.deepEqual(inventory(game),f.originalCards); + }); diff --git a/tests/kull-whole-common-cancel.test.ts b/tests/kull-whole-common-cancel.test.ts new file mode 100644 index 00000000..a70865a8 --- /dev/null +++ b/tests/kull-whole-common-cancel.test.ts @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { viewGame, type Game } from '../game/engine'; +import { TERRITORIES } from '../game/board'; +import { act, advance, clean, createKullNativeIxFixture, inventory, player } from './fixture-kull-native-ix'; +import { nextKullPurposeStep } from './fixture-kull-purpose'; + +/** Start from the native public five-seat setup before Spice Blow. The fixture + * selects original unopened cards but all questions, auctions, shipments and + * battle responses below are real actions. */ +function answered(target:'ixians'|'emperor') { + const f=createKullNativeIxFixture('ixians'); + let game=f.early; + game=act(game,'guild',{type:'card',card:f.truth}); + while (game.truthtrance?.stage==='priority') { + const seat=game.players.find(row=>!game.truthtrance!.passed.includes(row.id)); assert.ok(seat); + game=act(game,seat.id,{type:'truthPass'}); + } + const recipient=target==='ixians'?'emperor':'ixians'; + game=act(game,'guild',{type:'truthAsk',question:{kind:'turnActions',target,turn:game.turn, + mixed:{kind:'action',action:{kind:'payment',recipient,minimum:1}}}}); + assert.ok(viewGame(game,target).truthTurnActionAnswers?.includes('yes')); + game=act(game,target,{type:'truthAnswer',answer:'yes'}); + const row=game.turnActionPromises?.at(-1); assert.ok(row); + return {...f,game,event:row.event}; +} +function offer(game:Game,actor:string,card:string) { + assert.ok(viewGame(game,actor).responseControls?.cancelCards.includes(card)); + game=act(game,actor,{type:'card',mode:'cancel',card}); + const reaction=viewGame(game,'choam').kullReaction; assert.ok(reaction); + return {game,event:reaction.event}; +} + +for (const outcome of ['decline','success','counter'] as const) + void test(`whole payment answer survives real Guild income cancellation and Kull ${outcome}`,()=>{ + const f=answered('ixians'); + let game=advance(f.game,state=>state.phase===5 && state.active==='ixians' && clean(state)); + game=act(game,'ixians',{type:'ship',territory:'polar_sink',sector:0,amount:1,elite:0,allyPayment:0}); + assert.equal(game.response?.kind,'guildIncome'); + const choice=offer(game,'ixians',f.karama); game=choice.game; + game=outcome==='decline'?act(game,'choam',{type:'kullDecision',event:choice.event,decline:true}): + act(game,'choam',{type:'kullDecision',event:choice.event,source:'printed',card:f.kull}); + if (outcome==='counter') game=act(game,'emperor',{type:'card',mode:'cancel',card:f.counter}); + if (outcome==='success') game=advance(game,state=>!state.pendingKull && state.response?.kind!=='choamWorthless'); + assert.ok(game.turnActionPromises?.some(row=>row.event===f.event && !row.released)); + assert.equal(player(game,'ixians').hand.some(card=>card.id===f.karama),outcome==='success'); + assert.deepEqual(inventory(game),f.originalCards); + }); + +void test('whole payment answer survives real Basic Ixian cyborg-strength cancellation and Kull decline',()=>{ + const f=answered('emperor'); let game=f.game; + let target:{territory:string;sector:number}|null=null; + for (let count=0;count<1200 && game.phase!==6;count++) { + if (game.phase===5 && clean(game) && game.active) { + const actor=game.active, seat=player(game,actor); + if ((actor==='emperor'||actor==='ixians') && !seat.shipped) { + if (!target) { + const territory=TERRITORIES.find(row=>row.type==='stronghold' && row.sectors.some(s=>s!==game.storm) && + game.players.every(p=>!Object.keys(p.forces).some(key=>key.startsWith(`${row.id}:`)))); + assert.ok(territory); + target={territory:territory.id,sector:territory.sectors.find(s=>s!==game.storm)!}; + } + game=act(game,actor,{type:'ship',territory:target.territory,sector:target.sector,amount:1, + elite:actor==='ixians'?1:0,allyPayment:0}); + } else game=act(game,actor,{type:'endMovement'}); + continue; + } + const next=nextKullPurposeStep(game); assert.ok(next); + game=act(game,next.actor,next.action); + } + assert.equal(game.phase,6); assert.ok(target); + assert.equal(player(game,'emperor').forces[`${target.territory}:${target.sector}`],1); + assert.equal(player(game,'ixians').elites?.forces[`${target.territory}:${target.sector}`],1); + for (let count=0;count<30 && !game.battle;count++) { + const view=game.active?viewGame(game,game.active):null; + const choice=view?.battleChoices?.find(row=>row.territory===target!.territory && + (row.chooser??row.attacker)===game.active && [row.attacker,row.defender].includes(game.active!)); + if (choice) { + game=act(game,game.active!,{type:'chooseBattle',territory:target.territory, + target:game.active==='emperor'?'ixians':'emperor'}); + break; + } + const next=nextKullPurposeStep(game); assert.ok(next); + game=act(game,next.actor,next.action); + } + assert.equal(game.response?.kind,'eliteStrength'); + const choice=offer(game,'emperor',f.counter); game=choice.game; + game=act(game,'choam',{type:'kullDecision',event:choice.event,decline:true}); + assert.ok(game.turnActionPromises?.some(row=>row.event===f.event && !row.released)); + assert.ok(!player(game,'emperor').hand.some(card=>card.id===f.counter)); + assert.deepEqual(inventory(game),f.originalCards); +}); diff --git a/tests/kull-whole-guild-arrival-guard.test.ts b/tests/kull-whole-guild-arrival-guard.test.ts new file mode 100644 index 00000000..7e256d85 --- /dev/null +++ b/tests/kull-whole-guild-arrival-guard.test.ts @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine'; +import { act, advance, clean, inventory, player } from './fixture-kull-native-ix'; + +/** Genuine public Basic setup and movement; only unopened card and Spice + * order is selected. The live whole answer and both overlapping response + * windows arise through original actions, not installed pending state. */ +function source() { + let game=createGame('KULLBGGUILD',newPlayer('choam','CHOAM','choam'),false,['choam','ix']); + for (const id of ['emperor','beneGesserit','guild','harkonnen'] as const) + joinGame(game,newPlayer(id,id,id)); + game=act(game,'choam',{type:'moduleOption',module:'kullPreview',enabled:true}); + for (const seat of game.players) game=act(game,seat.id,{type:'ready'}); + game=act(game,'choam',{type:'start'}); + assert.equal(game.status,'setup'); assert.ok(game.kullPreview); + const originalCards=inventory(game), selected:Game['deck']=[]; + for (const kind of ['kull','karama','filler','truth','karama','filler'] as const) { + const index=game.deck.findIndex(card=>kind==='kull'?card.id==='ix-kull-wahad': + kind==='truth'?card.effect==='truthtrance':kind==='karama'?card.effect==='karama': + card.kind==='worthless' && card.id!=='ix-kull-wahad'); + assert.ok(index>=0); selected.push(game.deck.splice(index,1)[0]); + } + game.deck.unshift(...selected); + game=advance(game,state=>state.status==='playing'); + const [,karama,,truth]=selected.map(card=>card.id); + assert.ok(player(game,'emperor').hand.some(card=>card.id===karama)); + assert.ok(player(game,'guild').hand.some(card=>card.id===truth)); + const safe=game.spiceDeck.findIndex(card=>'territory' in card && !card.discovery); assert.ok(safe>=0); + game.spiceDeck.unshift(game.spiceDeck.splice(safe,1)[0]); + game=advance(game,state=>state.phase===2 && clean(state) && state.choamCharity?.turn===state.turn); + game=act(game,'guild',{type:'card',card:truth}); + while (game.truthtrance?.stage==='priority') { + const seat=game.players.find(row=>!game.truthtrance!.passed.includes(row.id)); assert.ok(seat); + game=act(game,seat.id,{type:'truthPass'}); + } + game=act(game,'guild',{type:'truthAsk',question:{kind:'turnActions',target:'emperor',turn:game.turn, + mixed:{kind:'action',action:{kind:'payment',recipient:'harkonnen',minimum:1}}}}); + assert.ok(viewGame(game,'emperor').truthTurnActionAnswers?.includes('yes')); + game=act(game,'emperor',{type:'truthAnswer',answer:'yes'}); + const promise=game.turnActionPromises?.at(-1); assert.ok(promise); + game=advance(game,state=>state.phase===5 && state.active==='emperor' && clean(state)); + game=act(game,'emperor',{type:'ship',territory:'polar_sink',sector:0,amount:1,elite:0,allyPayment:0}); + assert.equal(game.response?.kind,'guildIncome'); + assert.equal(game.decision?.kind,'advisor'); + assert.ok(!promise.released); + return {game,karama,event:promise.event,originalCards}; +} + +void test('live whole-answer preflight fences Guild income cancellation without consuming a simultaneous BG arrival decision',()=>{ + const f=source(); let game=f.game; + const before=structuredClone(game); + assert.ok(!viewGame(game,'emperor').responseControls?.cancelCards.includes(f.karama)); + assert.throws(()=>act(game,'emperor',{type:'card',mode:'cancel',card:f.karama})); + assert.deepEqual(game,before); + assert.equal(game.pendingKull ?? null,null); + assert.deepEqual(game.decision,before.decision); + assert.ok(game.turnActionPromises?.some(row=>row.event===f.event && !row.released)); + for (let count=0;game.response?.kind==='guildIncome' && count!game.response!.passed.includes(row.id)); assert.ok(seat); + game=act(game,seat.id,{type:'passResponse'}); + } + assert.equal(game.response,null); + assert.deepEqual(game.decision,before.decision); + game=act(game,'beneGesserit',{type:'decision',accept:false}); + assert.equal(game.decision,null); + assert.deepEqual(inventory(game),f.originalCards); +});