From 07f1f1f2aec46eb1c1bb44771dd4396e3b330519 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:37:33 +1100 Subject: [PATCH] Support whole-turn answers with Basic Kull --- components/game-table.tsx | 2 +- docs/CHOAM_KULL_DESIGN.md | 29 +++ docs/CURRENT_STATUS.md | 13 +- docs/RULE_DECISIONS.md | 8 + game/engine.ts | 117 ++++++++++- game/faction-reference.ts | 2 +- game/reference.ts | 5 +- game/turn-action-promises.ts | 8 +- ...l-richese-whole-turn-counter-owner.test.ts | 84 ++++++++ tests/kull-richese-whole-turn-runtime.test.ts | 184 ++++++++++++++++++ tests/kull-whole-auction-owner-guard.test.ts | 89 +++++++++ tests/kull-whole-counter-owner-guard.test.ts | 74 +++++++ tests/kull-whole-hidden-auction.test.ts | 97 +++++++++ tests/kull-whole-original-owner-guard.test.ts | 68 +++++++ tests/kull-whole-turn-scope-controls.test.tsx | 100 ++++++++++ tests/public-choam-kull-controls.test.tsx | 4 +- 16 files changed, 870 insertions(+), 14 deletions(-) create mode 100644 tests/kull-richese-whole-turn-counter-owner.test.ts create mode 100644 tests/kull-richese-whole-turn-runtime.test.ts create mode 100644 tests/kull-whole-auction-owner-guard.test.ts create mode 100644 tests/kull-whole-counter-owner-guard.test.ts create mode 100644 tests/kull-whole-hidden-auction.test.ts create mode 100644 tests/kull-whole-original-owner-guard.test.ts create mode 100644 tests/kull-whole-turn-scope-controls.test.tsx diff --git a/components/game-table.tsx b/components/game-table.tsx index 82c7a198..f46f18f7 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -2253,7 +2253,7 @@ export function GameTable({ Structured bid, alliance, Revival, bribe and combined turn-action questions are unavailable. : <> Requires Basic CHOAM with the CHOAM and Ix decks plus each faction’s deck, and no other optional modules. - Combined turn-action Truthtrance questions are unavailable. + Combined turn-action questions are available; some Karama uses remain unavailable while your answer is binding. Some Kull reactions may be unavailable in complex response windows. }{' '} Unfunded non-CHOAM Karama-dependent bids remain deferred. This does not represent complete CHOAM rules. diff --git a/docs/CHOAM_KULL_DESIGN.md b/docs/CHOAM_KULL_DESIGN.md index 0c235f41..d09cb778 100644 --- a/docs/CHOAM_KULL_DESIGN.md +++ b/docs/CHOAM_KULL_DESIGN.md @@ -1,5 +1,34 @@ # Kull Wahad: source limits and Karama interruption design +## Basic whole-turn answers with Kull — 29 October 2026 + +**Prototyped / Partial.** Combined whole-turn Truthtrance questions are +available in the supported bare Basic printed-Kull profile, including Richese +and Semuta. The existing online ordering is provisional. Answers remain bound +through the Kull offer, a successful interception or a distinct counter, and +their supported fresh-discard reactions. The owner cannot spend a card that +would remove the last proven completion of an answer. The ordinary shipment +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 +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 +restrictions. Advanced, Nexus Kull, special-cache Kull, other excluded modules +and unfunded non-CHOAM overbid recovery remain outside this slice. Full rules +and interactions are not certified. Fixtures use genuine setup and actions +with selected or staged physical cards. +[Kull and Semuta](../tests/kull-richese-whole-turn-runtime.test.ts), +[counter owner](../tests/kull-richese-whole-turn-counter-owner.test.ts), +[auction owner](../tests/kull-whole-auction-owner-guard.test.ts), +[counter cost](../tests/kull-whole-counter-owner-guard.test.ts), +[original cost](../tests/kull-whole-original-owner-guard.test.ts), +[hidden lot comparison](../tests/kull-whole-hidden-auction.test.ts), +[scope and controls](../tests/kull-whole-turn-scope-controls.test.tsx). + ## Basic alliance answers with Richese — 29 October 2026 **Prototyped / Partial.** Simple, grouped and mixed current-Nexus alliance diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 9e537ca6..3ea22059 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,17 @@ open mode gates. ## Current checkpoint and work +**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 +counter continuations; the original and counter owners must retain a proven +completion. The auction quote masks an unknown purchased face and keeps future +lots in place. Some Karama uses and completion witnesses remain conservatively +fenced, and broader profiles and full rules remain unfinished. Fifteen related +files pass 180 cases; typecheck, lint and the production build pass. Fixtures select or stage +physical cards, and the hidden-lot comparison retains a real payment alternative. +[Scope](CHOAM_KULL_DESIGN.md#basic-whole-turn-answers-with-kull--29-october-2026). + **Basic Richese and alliance Truthtrance — 29 October. Prototyped / Partial.** Simple, grouped and mixed alliance answers now continue through Semuta in the supported bare Basic Richese profile, with or without printed Kull. The @@ -25,7 +36,7 @@ later Kull reactions retain completed answer history. Twelve related files pass 72 cases, including actual Nexus actions, Semuta choices, bot answers and rendered controls. Typecheck, lint and the production build pass. Fixtures select unopened cards and explicitly stage some -physical cards or forces. Whole-turn questions, special-cache Kull and broader +physical cards or forces. At that checkpoint whole-turn questions, special-cache Kull and broader profiles remain unfinished. [Scope](CHOAM_KULL_DESIGN.md#basic-alliance-answers-with-richese--29-october-2026). **Basic Richese and Revival Truthtrance — 29 October. Prototyped / Partial.** diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index f4db0391..5bc6b0fd 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,13 @@ # Rule decision index +## Basic whole-turn answers with Kull — 29 October 2026 + +The supported bare Basic profile admits combined whole-turn answers through +printed Kull and Semuta using the existing provisional online ordering. Some +Karama continuations are conservatively fenced until their effects can be +quoted without hidden or foreign outcomes. +[Scope](CHOAM_KULL_DESIGN.md#basic-whole-turn-answers-with-kull--29-october-2026). + ## Basic Richese alliance answers — 29 October 2026 The supported bare Basic profile retains simple, grouped and mixed alliance diff --git a/game/engine.ts b/game/engine.ts index 42fbab3a..f04623a4 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1618,6 +1618,8 @@ export type Game = { revivalReceipt?: true; /** New Kull/Semuta attempts retain the original alliance answer ledger. */ allianceReceipt?: true; + /** New Basic Kull attempts retain whole-answer order and progress. */ + turnActionReceipt?: true; stage: 'offer' | 'counter'; selection?: { source: 'printed' | 'nexus'; card: string; nexusEvent?: string }; intent: { kind: 'ordinary'; use: KaramaUse } | { kind: 'special'; use: SpecialKaramaIntent }; @@ -10020,6 +10022,10 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string requireRule(next.declaration && next.use, 'The Kull counter lost its saved continuation.'); g.pendingKull = next.frame; g.pendingChoamWorthless = next.declaration; + // The verified Semuta exchange may legitimately advance a whole answer. + // Rebind only that new-format receipt before the original counter resumes. + if (result && g.pendingKull.turnActionReceipt) + g.pendingKull.signature = kullSignature(g, g.pendingKull); completeKarama(g, getPlayer(g, next.player), next.use, pending.batch.entries[0].card); } return; @@ -18288,6 +18294,8 @@ function responseCancelCards( return p.hand .filter((card) => { if (karamaSpendingBlock(g, p, card, karamaUsePurpose(g, {kind:'cancel', response}))) return false; + if (g.kullPreview && byFaction(g, 'choam')?.id !== p.id && + !kullOriginalWholeCostAllowed(g, p, card, {kind:'cancel', response})) return false; const window = card.kind === 'special' && card.effect === 'karama' && g.response === response ? karamaBattleCancelOpportunity(g, p.id) : null; if (!window || !g.turnActionPromises) return true; @@ -19088,7 +19096,8 @@ function assertKaramaPromiseFeasibility( use.response.kind === 'choamWorthless' && use.response.intent === 'Kull Wahad') { const moves = liveMovementPromises(g.movementPromises ?? [], p.id, g.turn); const expressions = liveMovementExpressionPromises(g.movementExpressionPromises ?? [], p.id, g.turn); - if (moves.length || expressions.length) { + const whole = liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn); + if ((moves.length || expressions.length) && !whole.length) { if (!costCard) return false; const projected = structuredClone(g); const owner = getPlayer(projected, p.id); @@ -19103,6 +19112,18 @@ function assertKaramaPromiseFeasibility( throw error; } } + if (whole.length) { + if (!costCard || g.pendingKull.intent.kind !== 'ordinary') return false; + const projected = structuredClone(kullSource(g, g.pendingKull)); + getPlayer(projected, p.id).hand = getPlayer(projected, p.id).hand.filter(card => card.id !== costCard.id); + const original = getPlayer(projected, g.pendingKull.owner); + const card = original.hand.find(row => row.id === g.pendingKull!.card); + if (!card) return false; + const tail = projectKullOrdinaryOutcome(projected, original, card, g.pendingKull.intent.use); + if (!tail || ((moves.length || expressions.length) && + !groundMovementCompletion(tail, p.id, moves, expressions)) || + !turnActionCompletion(tail, p.id)) return false; + } } const battlePromises = g.battle && !g.battle.revealed && !g.battle.plans[p.id] @@ -19211,7 +19232,8 @@ function kullCostPromisesAllow(g: Game, p: Player, card: Card, fuelSource: 'prin ? (source.battle.truthPromises ?? []).filter(promise => promise.player === p.id && !promise.released) : []; const shipmentPromises = liveShipmentPromises(source.shipmentPromises ?? [], p.id, source.turn); - if (!battlePromises.length && !shipmentPromises.length) return true; + const wholePromises = liveTurnActionPromises(source.turnActionPromises ?? [], p.id, source.turn); + if (!battlePromises.length && !shipmentPromises.length && !wholePromises.length) return true; for (const success of [true, false]) { const projected = structuredClone(source); const owner = getPlayer(projected, p.id); @@ -19230,8 +19252,19 @@ function kullCostPromisesAllow(g: Game, p: Player, card: Card, fuelSource: 'prin projected.response = null; projected.decision = null; projected.pendingKarama = null; + let wholeSource = projected; + if (!success && wholePromises.length && g.pendingKull) { + if (g.pendingKull.intent.kind !== 'ordinary') return false; + const original = getPlayer(projected, 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); + if (!resumed) return false; + wholeSource = resumed; + } if ((battlePromises.length && !findReachableBattlePlan(projected, owner, { promises: battlePromises })) || - (shipmentPromises.length && !findShipmentCompletion(projected, owner, shipmentPromises))) + (shipmentPromises.length && !findShipmentCompletion(projected, owner, shipmentPromises)) || + (wholePromises.length && !turnActionCompletion(wholeSource, p.id))) return false; } return true; @@ -19287,6 +19320,8 @@ function kullSignature(g: Game, frame: KullFrame): string { ...(frame.allianceReceipt ? {allianceReceipt:frame.allianceReceipt,alliancePromises:source.alliancePromises, allianceExpressionPromises:source.allianceExpressionPromises,mixedAllianceQuestions:source.mixedAllianceQuestions, alliancePromiseSequence:source.alliancePromiseSequence} : {}), + ...(frame.turnActionReceipt ? {turnActionReceipt:frame.turnActionReceipt, + turnActionPromises:source.turnActionPromises,travelPromiseOrder:source.travelPromiseOrder} : {}), }); } /** Only the verified pre-source printed profile may bind its old held cost. */ @@ -19342,6 +19377,9 @@ function kullIntegrity(g: Game): void { alliancePromiseModeSupported(g) && !!(g.alliancePromises?.length || g.allianceExpressionPromises?.length || g.mixedAllianceQuestions?.length), 'The Kull attempt has lost its original Basic alliance-answer ledger.'); + requireRule(frame.turnActionReceipt === undefined || frame.turnActionReceipt === true && + turnActionPromiseModeSupported(g) && !!g.turnActionPromises?.length, + 'The Kull attempt has lost its original whole-answer ledger.'); validateKullAttemptStamp(g, frame, physicalTreacheryCards(g)); requireRule(Number.isSafeInteger(g.kullSequence) && g.kullSequence! > 0 && frame.event === JSON.stringify(['kull', g.turn, g.phase, g.kullSequence, frame.player, frame.owner]), @@ -19432,6 +19470,8 @@ function beginKullAttempt(g: Game, p: Player, card: Card, intent: KullFrame['int ...(g.semutaPreview && alliancePromiseModeSupported(g) && (g.alliancePromises?.length || g.allianceExpressionPromises?.length || g.mixedAllianceQuestions?.length) ? { allianceReceipt: true as const } : {}), + ...(turnActionPromiseModeSupported(g) && g.turnActionPromises?.length + ? { turnActionReceipt: true as const } : {}), worthless: structuredClone(g.pendingChoamWorthless ?? null), signature: '', }; // Payment dispatch removed this control before entering its native branch. @@ -19457,6 +19497,59 @@ 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 + * 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 !== '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); + if (use.kind !== 'shipment' && (!g.auction || !lot)) return null; + 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 (window && card.kind === 'special' && card.effect === 'karama') recordNativeTurnActionWindowCard( + trial, player.id, use.kind === 'shipment' ? 'karamaShipment' : 'karamaAuction', window.source); + if (use.kind === 'shipment') trial.karamaShipping = {player:use.recipient, owner:player.id, card:card.id}; + else { + const auction = g.auction!, actual = auction.cards[auction.index]; + if (!actual) return null; + if (livePhaseBidPromises(g.phaseBidPromises ?? [], player.id, g.turn).some(row => row.answer)) return null; + const known = player.faction === 'atreides' && !!auction.peekKnown; + const opaqueId = JSON.stringify(['kull-opaque-lot', g.turn, auction.index, player.id]); + const acquired: Card = known ? structuredClone(actual) : {id:opaqueId, name:'Unknown card', kind:'special'}; + owner.hand.push(acquired); + if (!known) trial.auction!.cards[auction.index] = structuredClone(acquired); + if (!known) (trial as TurnActionCardProbe)._turnActionUnknownCards = [ + ...((trial as TurnActionCardProbe)._turnActionUnknownCards ?? []), opaqueId, + ]; + const original = liveTurnActionPromises(trial.turnActionPromises ?? [], player.id, trial.turn); + trial.turnActionPromises = turnActionRule(() => closeTurnActionBidding( + trial.turnActionPromises ?? [], trial.turn, lot!.source, player.id)); + if (window) trial.turnActionPromises = turnActionRule(() => closeTurnActionWindowCardPlay( + trial.turnActionPromises!, player.id, trial.turn, 'karamaAuction', window.source)); + if (original.some(row => trial.turnActionPromises?.find(next => next.event === row.event)?.released)) return null; + // Keep unawarded lots in their original custody while preventing this + // closed lot from becoming another bid or exposing its hidden face. + trial.currentAuctionSale = {winner:player.id, amount:auction.bid, free:true, origin:'normal', seller:null}; + reconcileBidPromises(trial, {actor:player.id, action:{type:'card'}}); + } + trial.truthtrance = null; trial.response = null; trial.decision = null; + trial.pendingKarama = null; trial.phaseOpening = null; trial.pendingKull = null; + return trial; +} +/** Before interrupting an owned Karama, prove its known original use can + * preserve the actor's whole answer if CHOAM declines or is countered. */ +function kullOriginalWholeCostAllowed(g: Game, p: Player, card: Card, use: KaramaUse): boolean { + if (!liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn).length) return true; + try { + const trial = projectKullOrdinaryOutcome(g, p, card, use); + return !!trial && !!turnActionCompletion(trial, p.id); + } catch (error) { + if (error instanceof RuleError) return false; + throw error; + } +} function decideKull(g: Game, p: Player, action: Action): void { const frame = g.pendingKull!; requireRule(action.type === 'kullDecision' && action.event === frame.event && @@ -19493,6 +19586,9 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered const blocked = karamaSpendingBlock(g, p, card, karamaUsePurpose(g, use)); requireRule(!blocked, blocked ?? 'This card cannot be spent as Karama.'); validateKaramaUse(g, p, use, card); + if (!kullOffered && !g.pendingKull && g.kullPreview && byFaction(g, 'choam')?.id !== p.id) + requireRule(kullOriginalWholeCostAllowed(g, p, card, use), + 'Retain a legal completion of your whole Truthtrance answer if this Karama resolves.'); const promiseProven = assertKaramaPromiseFeasibility(g, p, use, card); requireRule(!g.pendingKull || promiseProven, 'Kull preview cannot accept this unproven Truthtrance counter continuation.'); @@ -21982,6 +22078,17 @@ function turnActionFreshDiscardTail(source: Game, game.pendingKarama = structuredClone(continuation.resume.pendingKarama); game.phaseOpening = structuredClone(continuation.resume.phaseOpening); } + if (continuation.kind === 'kullCounterDiscard' && continuation.frame.intent.kind === 'ordinary') { + // Passing the counter's discard resumes the original Karama, not the + // already-countered Kull response. Quote its known effects without + // executing the parent or sampling an unopened auction card. + const source = structuredClone(game); + Object.assign(source, structuredClone(continuation.frame.resume)); + const original = getPlayer(source, continuation.frame.owner); + const card = original.hand.find(held => held.id === continuation.frame.card); + const projected = card && projectKullOrdinaryOutcome(source, original, card, continuation.frame.intent.use); + if (projected) return projected; + } return game; } /** Fresh use precedes every later witness. An uncommitted hidden face cannot @@ -22391,6 +22498,9 @@ function bindTurnActionTruth(g: Game, p: Player, claim: TurnActionClaim, answer: appendTravelOrder(g, 'turnActions', g.turnActionPromises.length - 1); } function reconcileTurnActionPromises(g: Game, actor?: string, action?: Action, sourcePhase = g.phase) { + const suspendedKull = g.pendingTreacheryDiscard?.continuation.kind === 'kullSuccessDiscard' || + g.pendingTreacheryDiscard?.continuation.kind === 'kullCounterDiscard'; + if (g.pendingKull || suspendedKull) return; reconcileTurnActionCombat(g); reconcileTurnActionWindowCards(g); for (const row of g.turnActionPromises ?? []) { @@ -22493,6 +22603,7 @@ function projectedTurnActionGuide(g: Game, player: Player): { event: string; act return action ? { event: row.event, action: { ...action, turnActionPromiseEvent: row.event, turnActionPromisePhase: g.phase } } : null; } + if (g.pendingKull) return null; const ownedBox = g.decision?.kind === 'nullentropy' && g.pendingNullentropy?.player === player.id; const ownedInvoice = g.decision?.kind === 'auctionPayment' && g.decision.player === player.id; const saphoExtortion = g.decision?.kind === 'moritaniExtortion' && diff --git a/game/faction-reference.ts b/game/faction-reference.ts index 20eba0ee..02fee669 100644 --- a/game/faction-reference.ts +++ b/game/faction-reference.ts @@ -14,7 +14,7 @@ export const FACTION_RULES: Record< 'Use Kulon on your movement turn to add one territory of range. Use La La La during Revival to prevent a player’s free force returns for the phase; a reactive decision lets you stop a pending free revival before it resolves. Both cards have Karama responses before discard.', 'Trip to Gamont returns one other player’s force from a chosen sector to its reserves during Mentat. Elite types are preserved. A Karama response precedes the return, and CHOAM gets a final opportunity after the closing market before victory is checked.', 'Baliset prevents a player from moving into a territory occupied by CHOAM for the turn, while permitting shipment. Jubba Cloak protects CHOAM forces in one territory from a moving storm. Both powers have Karama responses before the card is discarded.', - 'New supported Basic lobbies with CHOAM and the CHOAM and Ix decks enable the Kull preview by default; the host may turn it off. Each selected faction needs its deck, and other optional modules remain outside this profile. React to a legal Karama attempt with held Kull Wahad before costs. The original card stays reserved; only a different eligible Karama can counter before the phase ban applies. Success spends Kull, retains the original unplayed card and prevents the activating player’s Karama uses for the phase. Decline or prevention resumes the original once; a prevented Kull stays held but its physical card’s effect is blocked that phase. With Richese, supported Kull and counter discards offer Semuta; ordinary bribe, Revival and alliance, simple and mixed current-lot bid, and phase-wide bid questions are supported. Combined turn-action questions and unfunded non-CHOAM Karama-dependent overbids remain deferred; see the Kull guide. Complete CHOAM rules remain unfinished.', + 'New supported Basic lobbies with CHOAM and the CHOAM and Ix decks enable the Kull preview by default; the host may turn it off. Each selected faction needs its deck, and other optional modules remain outside this profile. React to a legal Karama attempt with held Kull Wahad before costs. The original card stays reserved; only a different eligible Karama can counter before the phase ban applies. Success spends Kull, retains the original unplayed card and prevents the activating player’s Karama uses for the phase. Decline or prevention resumes the original once; a prevented Kull stays held but its physical card’s effect is blocked that phase. With Richese, supported Kull and counter discards offer Semuta; ordinary bribe, Revival and alliance, simple and mixed current-lot bid, and phase-wide bid questions are supported. Combined turn-action questions are available in the supported Basic profile, with some Karama uses still guarded. Unfunded non-CHOAM Karama-dependent overbids remain deferred; see the Kull guide. Complete CHOAM rules remain unfinished.', ], advanced: [ 'Once per game, spend a real Karama and discard selected other cards for three spice each. The cash-in can occur during pending phase or power decisions, while committed battle cards remain protected. See the CHOAM special Karama topic for funding and timing details.', diff --git a/game/reference.ts b/game/reference.ts index dc79c799..0bb6ee66 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3316,6 +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.', '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.', @@ -4862,7 +4863,7 @@ export const RULE_TOPICS: RuleTopic[] = [ summary: 'React before a Karama is played; a distinct counter can stop Kull before its phase restriction begins.', steps: [ 'In a new supported Basic lobby with CHOAM and the CHOAM and Ix decks, printed Kull is enabled by default as a development preview. The host may turn it off to keep the broader non-Kull Truthtrance options. Basic supports Richese, Ixians, Tleilaxu, Ecaz, Moritani and classic factions with each seated faction’s deck and no other optional modules. In an Advanced preview lobby, the host may explicitly select printed Kull with CHOAM, classic factions and exactly the CHOAM and Ix decks, without optional modules. Advanced supports Bene Gesserit substitutions and legal special powers. Both previews withhold some structured questions and defer unfunded non-CHOAM Karama-dependent bids. Existing games are not retrofitted, and this warning does not certify complete rules.', - 'In the supported Basic printed Kull preview, simple and mixed current-lot ordinary bid, phase-wide bid, ordinary bribe, Revival and alliance questions are available, including with Richese and Semuta. A pending Kull response holds their controls while the original transaction resolves. Combined turn-action questions and wider interactions remain incomplete.', + 'In the supported Basic printed Kull preview, simple and mixed current-lot ordinary bid, phase-wide bid, ordinary bribe, Revival and alliance questions are available, including with Richese and Semuta. Combined turn-action questions are also available in this profile. A pending Kull response holds their controls while the original transaction resolves; some Karama uses remain guarded. Wider interactions remain incomplete.', 'Another player’s legal Karama cancellation, shipment-rate use, immediate auction purchase, winning-bid payment or supported special activation pauses before its card and other costs. CHOAM sees the attempted player and purpose and may decline or choose a legal held Kull Wahad. Each Use names its printed source explicitly. The separate Nexus Kull guide covers additional Cunning costs in its new profile only. The same public opportunity appears without Kull in hand, so waiting does not disclose hidden ownership. Invalid attempts never open this window; CHOAM does not receive a self-targeted Kull offer.', 'Decline resumes the original attempt once. Declaring Kull opens the ordinary CHOAM-effect response. Kull is still pending: its target may counter with a different eligible physical Karama before any phase ban applies. The interrupted card stays reserved and cannot serve as its own counter. Other eligible responders use their existing response controls.', 'If everyone allows Kull, CHOAM discards the exact Kull card. The original attempted card remains in its owner’s hand; its effect and additional costs do not occur, and an attempted special once-per-game use remains unspent. Basic prevents that player’s Karama activations for this phase. A new Advanced attempt instead prevents only the specific effect: shipment pricing, free Treachery purchase, that faction’s special, or cancellation of the particular advantage. Other effects remain available; printed and substituted Karama share the same restriction. An allied shipment recipient is not automatically the restricted player.', @@ -4877,7 +4878,7 @@ export const RULE_TOPICS: RuleTopic[] = [ example: 'Bene Gesserit attempts to use Baliset as Karama. CHOAM declares Kull. Baliset cannot also counter Kull; a different eligible Karama may. If Kull succeeds, Baliset stays held; in a new Advanced attempt BG cannot repeat that specific Karama effect this phase but may use another legal effect. If a distinct counter prevents Kull, the original Baliset attempt proceeds to its normal conversion response.', related: ['choam-worthless', 'nexus-choam-kull', 'faction-choam', 'card-karama', 'advanced-beneGesserit', 'special-karama', 'bidding', 'choam-karama', 'nexus-cards', 'implementation-checklist'], checklist: [ - { area: 'Implementation', status: 'Partial', detail: 'Selectable Kull binds typed intents, distinct physical counters and Basic/legacy all-purpose or new Advanced semantic-effect restrictions. Different effects remain playable; phase expiry is provisional in Advanced. Basic printed Kull admits native Richese, Ixians, Tleilaxu, Ecaz and Moritani; supported clean Kull discards offer Semuta before their suspended parent resumes. Simple and mixed current-lot bid, phase-wide bid, ordinary bribe, Revival and alliance commitments can coexist with this preview including Richese. Deferred overbid recovery, whole-turn answers and broader module support remain excluded.', evidence: ['game/choam-kull.ts', 'game/kull-purpose.ts', 'game/bribe-promises.ts', 'game/engine.ts', 'tests/kull-bid-runtime.test.ts', 'tests/kull-phase-bid-runtime.test.ts', 'tests/kull-richese-phase-bid-runtime.test.ts', 'tests/kull-richese-current-bid-runtime.test.ts', 'tests/kull-richese-mixed-bid-runtime.test.ts', 'tests/kull-richese-bribe-runtime.test.ts', 'tests/kull-richese-bribe-truth-semuta.test.ts', 'tests/kull-richese-bribe-table-controls.test.tsx', 'tests/kull-richese-revival-commitment.test.ts', 'tests/kull-richese-revival-truth-semuta.test.ts', 'tests/kull-richese-alliance-semuta.test.ts', 'tests/kull-richese-alliance-worm-ride.test.ts', 'tests/kull-richese-alliance-table-controls.test.tsx', 'tests/kull-truth-revival-runtime.test.ts', 'tests/kull-alliance-runtime.test.ts', 'tests/kull-richese-semuta-runtime.test.ts'] }, + { area: 'Implementation', status: 'Partial', detail: 'Selectable Kull binds typed intents, distinct physical counters and Basic/legacy all-purpose or new Advanced semantic-effect restrictions. Different effects remain playable; phase expiry is provisional in Advanced. Basic printed Kull admits native Richese, Ixians, Tleilaxu, Ecaz and Moritani; supported clean Kull discards offer Semuta before their suspended parent resumes. Simple and mixed current-lot bid, phase-wide bid, ordinary bribe, Revival and alliance commitments can coexist with this preview including Richese. Combined turn-action answers use guarded Kull continuation; some cancellation parents, deferred overbid recovery and broader module support remain excluded.', evidence: ['game/choam-kull.ts', 'game/kull-purpose.ts', 'game/bribe-promises.ts', 'game/engine.ts', 'tests/kull-bid-runtime.test.ts', 'tests/kull-phase-bid-runtime.test.ts', 'tests/kull-richese-phase-bid-runtime.test.ts', 'tests/kull-richese-current-bid-runtime.test.ts', 'tests/kull-richese-mixed-bid-runtime.test.ts', 'tests/kull-richese-bribe-runtime.test.ts', 'tests/kull-richese-bribe-truth-semuta.test.ts', 'tests/kull-richese-bribe-table-controls.test.tsx', 'tests/kull-richese-revival-commitment.test.ts', 'tests/kull-richese-revival-truth-semuta.test.ts', 'tests/kull-richese-alliance-semuta.test.ts', 'tests/kull-richese-alliance-worm-ride.test.ts', 'tests/kull-richese-alliance-table-controls.test.tsx', 'tests/kull-truth-revival-runtime.test.ts', 'tests/kull-alliance-runtime.test.ts', 'tests/kull-richese-semuta-runtime.test.ts'] }, { area: 'Player controls', status: 'Partial', detail: 'Owner-only legal held costs and decline use the Kull event; counters reuse ordinary response controls. Public viewers receive purpose/actors, not private intent payloads or hidden choices.', evidence: ['components/choam-kull.tsx', 'components/game-table.tsx'] }, { area: 'AI', status: 'Partial', detail: 'Legal preview participation uses projected reactor choices and ordinary distinct counter controls. Full strategy and difficulty calibration remain deferred.', evidence: ['game/bot-choam-kull.ts', 'game/bots.ts'] }, { area: 'Documentation', status: 'Partial', detail: 'Pending versus active restrictions, distinct custody, decline/prevention/success, BG-before-conversion and deferred overbid limits have internal explanations. Publisher-source and full-faction acceptance remain separate.', evidence: ['docs/CHOAM_KULL_DESIGN.md', 'docs/CHOAM_KULL_SOURCE_UPDATE.md', 'docs/NEXUS_CHOAM_RULES.md'] }, diff --git a/game/turn-action-promises.ts b/game/turn-action-promises.ts index 3d2fdc64..351b0430 100644 --- a/game/turn-action-promises.ts +++ b/game/turn-action-promises.ts @@ -1,5 +1,5 @@ import { TERRITORIES, validLocation } from './board'; -import { BribePromiseError, parseBribeClaim } from './bribe-promises'; +import { BribePromiseError, bribePromiseModeSupported, parseBribeClaim } from './bribe-promises'; import { BidPromiseError, parseBidClaim } from './bid-promises'; import { AlliancePromiseError, parseAllianceClaim } from './alliance-promises'; import { parseRevivalActionClaim, type RevivalActionClaim } from './mixed-revival-question'; @@ -34,10 +34,10 @@ export const TURN_ACTION_MAX_DEPTH = 4; /** Native whole answers retain Semuta's own discard continuation. Other * unsupported interruption/module envelopes stay separately gated. */ export function turnActionPromiseModeSupported(game: NativePromiseScope & { - kullPreview?: unknown; nexusKullPreview?: unknown; richeseBetrayalPreview?: unknown; + kullPreview?: unknown; semutaPreview?: unknown; nexusKullPreview?: unknown; richeseBetrayalPreview?: unknown; }): boolean { - return nativePromiseScopeSupported(game) && !game.kullPreview && - !game.nexusKullPreview && !game.richeseBetrayalPreview; + return nativePromiseScopeSupported(game) && !game.nexusKullPreview && !game.richeseBetrayalPreview && + (!game.kullPreview || game.kullPreview === true && !game.advanced && bribePromiseModeSupported(game)); } export type TurnActionLeaf = TravelActionClaim | StormDialClaim | { kind: 'payment'; recipient: string; minimum: number } | diff --git a/tests/kull-richese-whole-turn-counter-owner.test.ts b/tests/kull-richese-whole-turn-counter-owner.test.ts new file mode 100644 index 00000000..f17ab833 --- /dev/null +++ b/tests/kull-richese-whole-turn-counter-owner.test.ts @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { handLimit, viewGame, type Game } from '../game/engine'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { act, advance, attempt, clean, createKullRicheseSemutaFixture, inventory, player } from './fixture-kull-richese-semuta'; + +/** Genuine Basic setup, real cache purchase and movement window. Only the + * existing physical counter Karama is moved from Harkonnen to Richese and + * an original Truthtrance is moved to Harkonnen. No purse, force, pending + * response, clock, promise or fresh discard is installed. */ +function source() { + const f=createKullRicheseSemutaFixture(), game=structuredClone(f.game); + const zones=[game.deck,game.discard,...(game.richeseCache?[game.richeseCache]:[]), + ...(game.richeseRemoved?[game.richeseRemoved]:[]),...game.players.map(seat=>seat.hand)]; + const move=(id:string,recipient:string)=>{ + const zone=zones.find(cards=>cards.some(card=>card.id===id)); assert.ok(zone); + const [card]=zone.splice(zone.findIndex(row=>row.id===id),1); + const seat=player(game,recipient); assert.ok(seat.hand.lengthcard.effect==='truthtrance'); assert.ok(truth); + move(truth.id,'harkonnen'); + assert.ok(player(game,'richese').hand.some(card=>card.id===SEMUTA_DRUG_ID)); + assert.deepEqual(inventory(game),f.originalCards); + return {...f,game,truth:truth.id}; +} + +function passDiscard(game:Game, card:string):Game { + const batch=game.pendingTreacheryDiscard; assert.ok(batch?.reaction?.stage==='offer'); + assert.ok(batch.batch.entries.some(entry=>entry.card.id===card)); + const event=batch.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; +} + +void test('Richese may counter Kull then use Semuta on the resumed original Karama while preserving a whole answer',()=>{ + const f=source(); let game=act(f.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'}); + } + const mixed={kind:'or',terms:[ + {kind:'action',action:{kind:'cardPlay',effect:'semutaDrug'}}, + {kind:'action',action:{kind:'payment',recipient:'emperor',minimum:1}}, + ]}; + game=act(game,'harkonnen',{type:'truthAsk',question:{kind:'turnActions',target:'richese',turn:game.turn,mixed}}); + assert.ok(viewGame(game,'richese').truthTurnActionAnswers?.includes('yes')); + game=act(game,'richese',{type:'truthAnswer',answer:'yes'}); + const promise=game.turnActionPromises?.at(-1); assert.ok(promise); + const event=promise.event; + // A currently affordable payment is a real alternative, so passing the + // Truth discard does not evade the complete OR answer. + game=passDiscard(game,f.truth); + game=act(game,'emperor',attempt(f)); + const reaction=viewGame(game,'choam').kullReaction; assert.ok(reaction); + game=act(game,'choam',{type:'kullDecision',event:reaction.event,source:'printed',card:f.kull}); + assert.ok(viewGame(game,'richese').responseControls?.cancelCards?.includes(f.counter)); + game=act(game,'richese',{type:'card',mode:'cancel',card:f.counter}); + assert.ok(!game.turnActionPromises!.find(row=>row.event===event)?.released); + // The physical counter belongs to Richese and cannot be its own Semuta + // target. Its neutral discard completes first, then the suspended original + // Emperor Karama produces a distinct, claimable fresh discard. + game=passDiscard(game,f.counter); + if (!game.pendingTreacheryDiscard) + game=advance(game,state=>!!state.pendingTreacheryDiscard); + const pending=game.pendingTreacheryDiscard; assert.ok(pending?.reaction?.stage==='offer'); + assert.ok(pending.batch.entries.some(entry=>entry.card.id===f.karama)); + assert.equal(viewGame(game,'richese').semutaReaction?.canCommit,true); + game=act(game,'richese',{type:'semutaCommit',event:pending.batch.event}); + if (game.pendingTreacheryDiscard?.reaction?.stage==='select') + game=act(game,'richese',{type:'semutaSelect',event:pending.batch.event,card:f.karama}); + game=advance(game,clean); + const result=game.turnActionPromises!.find(row=>row.event===event); assert.ok(result); + assert.equal(result.cardPlays?.semutaDrug?.played,true); + assert.equal(result.fulfilled,true); + assert.ok(player(game,'richese').hand.some(card=>card.id===f.karama)); + assert.equal(game.karamaShipping?.owner,'emperor'); + assert.deepEqual(inventory(game),f.originalCards); +}); diff --git a/tests/kull-richese-whole-turn-runtime.test.ts b/tests/kull-richese-whole-turn-runtime.test.ts new file mode 100644 index 00000000..c2527e29 --- /dev/null +++ b/tests/kull-richese-whole-turn-runtime.test.ts @@ -0,0 +1,184 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { act, advance, clean, inventory, player } from './fixture-kull-richese-semuta'; + +/** Genuine Basic lobby, physical deal and Richese cache purchase. Only the + * still-unopened deal and one safe Spice position are selected; no played + * cards, force counts, purses, phase, pending response or promise is staged. */ +function source() { + let game = createGame('KULLRICHWHOLE', newPlayer('choam','CHOAM','choam'), false, ['choam','ix']); + for (const id of ['richese','emperor','harkonnen','guild'] 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','karama','filler','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],['guild',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===5 && state.active==='emperor' && + !player(state,'emperor').shipped && player(state,'richese').hand.some(card=>card.id===SEMUTA_DRUG_ID) && clean(state)); + assert.deepEqual(inventory(game),originalCards); + return {game,kull,karama,counter,truth,originalCards}; +} + +function semuta(game:Game, card:string, claim:boolean):Game { + const pending=game.pendingTreacheryDiscard; assert.ok(pending); + assert.equal(pending.reaction?.stage,'offer'); + assert.ok(pending.batch.entries.some(entry=>entry.card.id===card)); + const event=pending.batch.event; + if (claim) { + assert.equal(viewGame(game,'richese').semutaReaction?.canCommit,true); + game=act(game,'richese',{type:'semutaCommit',event}); + if (game.pendingTreacheryDiscard?.reaction?.stage==='select') + game=act(game,'richese',{type:'semutaSelect',event,card}); + } else { + 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; +} + +function ask(game:Game, truth:string, mixed:unknown, answer:'yes'|'no' = 'yes', target='emperor') { + 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'}); + } + assert.ok(viewGame(game,'guild').truthTurnActionTargets.includes(target)); + game=act(game,'guild',{type:'truthAsk',question:{kind:'turnActions',target,turn:game.turn,mixed}}); + const choices=viewGame(game,target).truthTurnActionAnswers; assert.ok(choices); assert.ok(choices.includes(answer)); + for (const difficulty of DIFFICULTIES) { + const view=viewGame(game,target); view.players.find(row=>row.id===target)!.bot=difficulty; + const action=botActions(view)[0]; assert.ok(action,difficulty); + assert.equal(action.type,'truthAnswer',difficulty); + assert.ok(choices.includes(action.answer as 'yes'|'no'|'unknown'),difficulty); + assert.doesNotThrow(()=>act(game,target,action),difficulty); + } + game=act(game,target,{type:'truthAnswer',answer}); + const promise=game.turnActionPromises?.at(-1); assert.ok(promise); + return {game,event:promise.event}; +} + +const paymentShipment = {kind:'and',terms:[ + {kind:'action',action:{kind:'payment',recipient:'guild',minimum:2}}, + {kind:'action',action:{kind:'ship',territory:'polar_sink',minimum:1}}, +]}; + +for (const claim of [false,true]) void test(`whole payment and shipment answer survives Semuta ${claim?'claim':'pass'} of Truthtrance`,()=>{ + const f=source(); const {game:initialGame,event}=ask(f.game,f.truth,paymentShipment); let game=initialGame; + const promise=structuredClone(game.turnActionPromises!.find(row=>row.event===event)); assert.ok(promise); + game=semuta(game,f.truth,claim); + assert.deepEqual(game.turnActionPromises!.find(row=>row.event===event),promise); + game=act(game,'emperor',{type:'bribe',target:'guild',amount:2}); + assert.equal(player(game,'guild').bribes,2); + game=act(game,'emperor',{type:'ship',territory:'polar_sink',sector:0,amount:1,elite:0,allyPayment:0}); + game=advance(game,clean); + assert.equal(game.turnActionPromises!.find(row=>row.event===event)?.fulfilled,true); + assert.equal(player(game,'richese').hand.some(row=>row.id===f.truth),claim); + assert.deepEqual(inventory(game),f.originalCards); +}); + +for (const counter of [false,true]) for (const claim of [false,true]) + void test(`whole payment and shipment answer survives Kull ${counter?'counter':'success'} and Semuta ${claim?'claim':'pass'}`,()=>{ + const f=source(); const {game:initialGame,event}=ask(f.game,f.truth,paymentShipment); let game=initialGame; + game=semuta(game,f.truth,false); + const before=structuredClone(game.turnActionPromises!.find(row=>row.event===event)); assert.ok(before); + game=act(game,'emperor',{type:'card',mode:'shipment',card:f.karama,target:'emperor'}); + const reaction=viewGame(game,'choam').kullReaction; assert.ok(reaction); + game=act(game,'choam',{type:'kullDecision',event:reaction.event,source:'printed',card:f.kull}); + assert.equal(game.pendingKull?.stage,'counter'); + assert.deepEqual(game.turnActionPromises!.find(row=>row.event===event),before); + if (counter) game=act(game,'harkonnen',{type:'card',mode:'cancel',card:f.counter}); + else game=advance(game,state=>!!state.pendingTreacheryDiscard); + assert.deepEqual(game.turnActionPromises!.find(row=>row.event===event),before); + game=semuta(game,counter?f.counter:f.kull,claim); + game=advance(game,clean); + assert.ok(!game.turnActionPromises!.find(row=>row.event===event)?.released); + game=act(game,'emperor',{type:'bribe',target:'guild',amount:2}); + game=act(game,'emperor',{type:'ship',territory:'polar_sink',sector:0,amount:1,elite:0,allyPayment:0}); + game=advance(game,clean); + assert.equal(game.turnActionPromises!.find(row=>row.event===event)?.fulfilled,true); + assert.equal(player(game,'richese').hand.some(row=>row.id===(counter?f.counter:f.kull)),claim); + assert.deepEqual(inventory(game),f.originalCards); + }); + +void test('successful Kull leaves a promised printed shipment-price use unfulfilled until it is impossible',()=>{ + const f=source(); + const window=JSON.stringify(['karamaShipment',f.game.turn,'emperor','original']); + const mixed={kind:'action',action:{kind:'windowCardPlay',effect:'karamaShipment',source:window}}; + const {game:initialGame,event}=ask(f.game,f.truth,mixed); let game=initialGame; + game=semuta(game,f.truth,false); + const before=structuredClone(game.turnActionPromises!.find(row=>row.event===event)); assert.ok(before); + game=act(game,'emperor',{type:'card',mode:'shipment',card:f.karama,target:'emperor'}); + const reaction=viewGame(game,'choam').kullReaction; assert.ok(reaction); + game=act(game,'choam',{type:'kullDecision',event:reaction.event,source:'printed',card:f.kull}); + assert.deepEqual(game.turnActionPromises!.find(row=>row.event===event),before); + game=advance(game,state=>!!state.pendingTreacheryDiscard); + assert.deepEqual(game.turnActionPromises!.find(row=>row.event===event),before); + game=semuta(game,f.kull,false); game=advance(game,clean); + const after=game.turnActionPromises!.find(row=>row.event===event); assert.ok(after); + assert.equal(after.fulfilled ?? false,false); + assert.equal(after.released,true); + assert.ok(player(game,'emperor').hand.some(row=>row.id===f.karama)); + assert.deepEqual(inventory(game),f.originalCards); +}); + +void test('whole Semuta Yes cannot voluntarily pass the known Truth discard that makes it possible',()=>{ + const f=source(); const mixed={kind:'action',action:{kind:'cardPlay',effect:'semutaDrug'}}; + const {game:initialGame,event}=ask(f.game,f.truth,mixed,'yes','richese'); let game=initialGame; + const pending=game.pendingTreacheryDiscard; assert.ok(pending); + const before=structuredClone(game); + assert.throws(()=>act(game,'richese',{type:'semutaPass',event:pending.batch.event})); + assert.deepEqual(game,before); + game=semuta(game,f.truth,true); + const promise=game.turnActionPromises!.find(row=>row.event===event); assert.ok(promise); + assert.equal(promise.cardPlays?.semutaDrug?.played,true); + assert.equal(promise.fulfilled,true); + assert.deepEqual(inventory(game),f.originalCards); +}); + +for (const counter of [false,true]) void test(`a whole Semuta-or-payment answer records Semuta on ${counter?'counter':'Kull'} discard`,()=>{ + const f=source(); const mixed={kind:'or',terms:[ + {kind:'action',action:{kind:'cardPlay',effect:'semutaDrug'}}, + {kind:'action',action:{kind:'payment',recipient:'guild',minimum:1}}, + ]}; + const {game:initialGame,event}=ask(f.game,f.truth,mixed,'yes','richese'); let game=initialGame; + // Payment remains a real affordable alternative, so the holder may pass + // the Truth discard without abandoning the whole answer. + game=semuta(game,f.truth,false); + const before=structuredClone(game.turnActionPromises!.find(row=>row.event===event)); assert.ok(before); + game=act(game,'emperor',{type:'card',mode:'shipment',card:f.karama,target:'emperor'}); + const reaction=viewGame(game,'choam').kullReaction; assert.ok(reaction); + game=act(game,'choam',{type:'kullDecision',event:reaction.event,source:'printed',card:f.kull}); + assert.deepEqual(game.turnActionPromises!.find(row=>row.event===event),before); + if (counter) game=act(game,'harkonnen',{type:'card',mode:'cancel',card:f.counter}); + else game=advance(game,state=>!!state.pendingTreacheryDiscard); + assert.deepEqual(game.turnActionPromises!.find(row=>row.event===event),before); + const cost=counter?f.counter:f.kull; + game=semuta(game,cost,true); game=advance(game,clean); + const promise=game.turnActionPromises!.find(row=>row.event===event); assert.ok(promise); + assert.equal(promise.cardPlays?.semutaDrug?.played,true); + assert.equal(promise.fulfilled,true); + assert.ok(player(game,'richese').hand.some(row=>row.id===cost)); + assert.deepEqual(inventory(game),f.originalCards); +}); diff --git a/tests/kull-whole-auction-owner-guard.test.ts b/tests/kull-whole-auction-owner-guard.test.ts new file mode 100644 index 00000000..8e40fc3e --- /dev/null +++ b/tests/kull-whole-auction-owner-guard.test.ts @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine'; +import { windowCardOpportunity } from '../game/window-card-play'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { act, advance, clean, inventory, player } from './fixture-kull-richese-semuta'; + +/** Genuine Basic setup and Richese cache purchase. Only unopened deal and + * Spice positions are selected; all answers, payments and auctions are real. */ +function source() { + let game=createGame('KULLWHOLEAUCTION',newPlayer('choam','CHOAM','choam'),false,['choam','ix']); + for (const id of ['richese','emperor','harkonnen','guild'] as const) joinGame(game,newPlayer(id,id,id)); + game=act(game,'choam',{type:'moduleOption',module:'kullPreview',enabled:true}); + for (const p of game.players) game=act(game,p.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','karama','filler','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],['guild',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=>player(state,'richese').hand.some(card=>card.id===SEMUTA_DRUG_ID) && + !!state.auction && state.auction.active==='emperor' && clean(state)); + const window=windowCardOpportunity(game,'karamaAuction'); assert.ok(window); + const lot=game.auction!.cards[game.auction!.index].id; + assert.deepEqual(inventory(game),originalCards); + return {game,kull,karama,truth,window,lot,originalCards}; +} +function passDiscard(game:Game):Game { + const pending=game.pendingTreacheryDiscard; assert.ok(pending?.reaction); + 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; +} +function answer(f:ReturnType,mixed:unknown,value:'yes'|'no') { + let game=act(f.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'}); + } + game=act(game,'guild',{type:'truthAsk',question:{kind:'turnActions',target:'emperor',turn:game.turn,mixed}}); + assert.ok(viewGame(game,'emperor').truthTurnActionAnswers?.includes(value)); + game=act(game,'emperor',{type:'truthAnswer',answer:value}); + const row=game.turnActionPromises!.at(-1); assert.ok(row); + if (game.pendingTreacheryDiscard) game=passDiscard(game); + return {game,event:row.event}; +} +for (const decline of [true,false]) void test(`an unrelated whole payment remains legal through Karama purchase and Kull ${decline?'decline':'success'}`,()=>{ + const f=source(); const {game:initialGame,event}=answer(f,{kind:'action',action:{kind:'payment',recipient:'guild',minimum:2}},'yes'); let game=initialGame; + game=act(game,'emperor',{type:'card',mode:'purchase',card:f.karama}); + const reaction=viewGame(game,'choam').kullReaction; assert.ok(reaction); + game=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 (!decline) game=advance(game,state=>!!state.pendingTreacheryDiscard); + if (game.pendingTreacheryDiscard) game=passDiscard(game); + game=advance(game,clean); + assert.ok(!game.turnActionPromises!.find(row=>row.event===event)?.released); + if (decline) assert.ok(player(game,'emperor').hand.some(card=>card.id===f.lot)); + else assert.ok(player(game,'emperor').hand.some(card=>card.id===f.karama)); + game=act(game,'emperor',{type:'bribe',target:'guild',amount:2}); + assert.equal(game.turnActionPromises!.find(row=>row.event===event)?.fulfilled,true); + assert.equal(player(game,'guild').bribes,2); + assert.deepEqual(inventory(game),f.originalCards); +}); +void test('a whole No to the original auction Karama rejects the printed purchase before Kull',()=>{ + const f=source(); const {game:initialGame,event}=answer(f,{kind:'action',action:{kind:'windowCardPlay', + effect:'karamaAuction',source:f.window.source}},'no'); let game=initialGame; + const before=structuredClone(game); + assert.throws(()=>act(game,'emperor',{type:'card',mode:'purchase',card:f.karama})); + assert.deepEqual(game,before); + assert.equal(game.pendingKull ?? null,null); + game=act(game,'emperor',{type:'bid',amount:1,allyPayment:0}); + assert.ok(!game.turnActionPromises!.find(row=>row.event===event)?.released); + assert.deepEqual(inventory(game),f.originalCards); +}); diff --git a/tests/kull-whole-counter-owner-guard.test.ts b/tests/kull-whole-counter-owner-guard.test.ts new file mode 100644 index 00000000..2516932c --- /dev/null +++ b/tests/kull-whole-counter-owner-guard.test.ts @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { handLimit, viewGame, type Game } from '../game/engine'; +import { act, advance, clean, createKullRicheseSemutaFixture, inventory, player } from './fixture-kull-richese-semuta'; + +/** Native Basic setup, physical deal, Richese cache purchase and the active + * Emperor shipment window are inherited. Existing Truthtrance and Richese + * Karama cards are moved to Richese's hand; no cash, pending actions or + * promises are staged. The distinct Karama keeps the counter window open. */ +function source() { + const f = createKullRicheseSemutaFixture(); + const game = structuredClone(f.game); + const zones = [game.deck, game.discard, ...(game.richeseCache ? [game.richeseCache] : []), + ...(game.richeseRemoved ? [game.richeseRemoved] : []), ...game.players.map(seat => seat.hand)]; + const zone = zones.find(cards => cards.some(card => card.effect === 'truthtrance')); assert.ok(zone); + const [truth] = zone.splice(zone.findIndex(card => card.effect === 'truthtrance'), 1); + assert.ok(player(game,'richese').hand.length < handLimit(player(game,'richese'))); + player(game,'richese').hand.push(truth); + const cache = game.richeseCache; assert.ok(cache); + const karamaIndex = cache.findIndex(card => card.id === 'richese-karama'); assert.ok(karamaIndex >= 0); + const [additionalKarama] = cache.splice(karamaIndex,1); + assert.ok(player(game,'richese').hand.length < handLimit(player(game,'richese'))); + player(game,'richese').hand.push(additionalKarama); + assert.deepEqual(inventory(game),f.originalCards); + assert.deepEqual(player(game,'harkonnen').hand.filter(card=>card.effect==='karama').map(card=>card.id),[f.counter]); + return {...f,game,truth:truth.id}; +} +function passDiscard(game:Game):Game { + const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction); + 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; +} + +void test('a Kull counter cannot spend the sole Karama promised for the open shipment-price window',()=>{ + const f=source(); let game=act(f.game,'richese',{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 windowSource=JSON.stringify(['karamaShipment',game.turn,'emperor','original']); + game=act(game,'richese',{type:'truthAsk',question:{kind:'turnActions',target:'harkonnen',turn:game.turn, + mixed:{kind:'action',action:{kind:'windowCardPlay',effect:'karamaShipment',source:windowSource}}}}); + assert.ok(viewGame(game,'harkonnen').truthTurnActionAnswers?.includes('yes')); + game=act(game,'harkonnen',{type:'truthAnswer',answer:'yes'}); + const promise=game.turnActionPromises!.at(-1); assert.ok(promise); + if (game.pendingTreacheryDiscard) game=passDiscard(game); + game=act(game,'emperor',{type:'card',mode:'shipment',card:f.karama,target:'emperor'}); + const opportunity=viewGame(game,'choam').kullReaction; assert.ok(opportunity); + game=act(game,'choam',{type:'kullDecision',event:opportunity.event,source:'printed',card:f.kull}); + assert.equal(game.pendingKull?.stage,'counter'); + assert.ok(viewGame(game,'richese').responseControls?.cancelCards.includes('richese-karama')); + assert.ok(!viewGame(game,'harkonnen').responseControls?.cancelCards.includes(f.counter)); + const before=structuredClone(game); + assert.throws(()=>act(game,'harkonnen',{type:'card',mode:'cancel',card:f.counter})); + assert.deepEqual(game,before); + assert.ok(!game.turnActionPromises!.find(row=>row.event===promise.event)?.released); + game=advance(game,state=>!!state.pendingTreacheryDiscard); + game=passDiscard(game); + game=advance(game,clean); + assert.ok(!game.turnActionPromises!.find(row=>row.event===promise.event)?.released); + game=act(game,'harkonnen',{type:'card',mode:'shipment',card:f.counter,target:'emperor'}); + const second=viewGame(game,'choam').kullReaction; + if (second) game=act(game,'choam',{type:'kullDecision',event:second.event,decline:true}); + if (game.pendingTreacheryDiscard) game=passDiscard(game); + game=advance(game,clean); + assert.equal(game.turnActionPromises!.find(row=>row.event===promise.event)?.fulfilled,true); + assert.deepEqual(inventory(game),f.originalCards); +}); diff --git a/tests/kull-whole-hidden-auction.test.ts b/tests/kull-whole-hidden-auction.test.ts new file mode 100644 index 00000000..c446ab0e --- /dev/null +++ b/tests/kull-whole-hidden-auction.test.ts @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine'; +import { windowCardOpportunity } from '../game/window-card-play'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { act, advance, clean, inventory, player } from './fixture-kull-richese-semuta'; + +/** Genuine Basic setup and Richese cache purchase. Only unopened deal and + * Spice positions are selected; all answers, payments and auctions are real. */ +function source() { + let game=createGame('KULLWHOLEAUCTION',newPlayer('choam','CHOAM','choam'),false,['choam','ix']); + for (const id of ['richese','emperor','harkonnen','guild'] as const) joinGame(game,newPlayer(id,id,id)); + game=act(game,'choam',{type:'moduleOption',module:'kullPreview',enabled:true}); + for (const p of game.players) game=act(game,p.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','karama','filler','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],['guild',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=>player(state,'richese').hand.some(card=>card.id===SEMUTA_DRUG_ID) && + !!state.auction && state.auction.active==='emperor' && clean(state)); + const window=windowCardOpportunity(game,'karamaAuction'); assert.ok(window); + const lot=game.auction!.cards[game.auction!.index].id; + assert.deepEqual(inventory(game),originalCards); + return {game,kull,karama,truth,window,lot,originalCards}; +} +function passDiscard(game:Game):Game { + const pending=game.pendingTreacheryDiscard; assert.ok(pending?.reaction); + 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; +} +function answer(f:ReturnType,mixed:unknown,value:'yes'|'no') { + let game=act(f.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'}); + } + game=act(game,'guild',{type:'truthAsk',question:{kind:'turnActions',target:'emperor',turn:game.turn,mixed}}); + assert.ok(viewGame(game,'emperor').truthTurnActionAnswers?.includes(value)); + game=act(game,'emperor',{type:'truthAnswer',answer:value}); + const row=game.turnActionPromises!.at(-1); assert.ok(row); + if (game.pendingTreacheryDiscard) game=passDiscard(game); + return {game,event:row.event}; +} + +void test('a whole-answer Karama purchase quote is independent of the unopened lot face',()=>{ + const f=source(); + const mixed={kind:'or',terms:[ + {kind:'action',action:{kind:'cardPlay',effect:'ghola'}}, + {kind:'action',action:{kind:'payment',recipient:'guild',minimum:2}}, + ]}; + const {game,event}=answer(f,mixed,'yes'); + const makeVariant=(effect:'ghola'|'other'):Game=>{ + const trial=structuredClone(game), auction=trial.auction; assert.ok(auction); + const matches=(card:Game['deck'][number])=>effect==='ghola' + ? card.effect==='ghola' : card.effect!=='ghola'; + if (!matches(auction.cards[auction.index])) { + const deckIndex=trial.deck.findIndex(matches); + if (deckIndex>=0) [auction.cards[auction.index],trial.deck[deckIndex]]= + [trial.deck[deckIndex],auction.cards[auction.index]]; + else { + const other=auction.cards.findIndex((card,index)=>index>auction.index && matches(card)); + assert.ok(other>=0,'Need two original unopened faces for the two variants'); + [auction.cards[auction.index],auction.cards[other]]=[auction.cards[other],auction.cards[auction.index]]; + } + } + return trial; + }; + const variants=[makeVariant('ghola'),makeVariant('other')]; + assert.notEqual(variants[0].auction!.cards[variants[0].auction!.index].id, + variants[1].auction!.cards[variants[1].auction!.index].id); + assert.equal(variants[0].auction!.cards[variants[0].auction!.index].effect,'ghola'); + for (let trial of variants) { + assert.deepEqual(inventory(trial),f.originalCards); + assert.deepEqual(player(trial,'emperor').hand,player(game,'emperor').hand); + trial=act(trial,'emperor',{type:'card',mode:'purchase',card:f.karama}); + assert.ok(viewGame(trial,'choam').kullReaction); + assert.ok(!trial.turnActionPromises!.find(row=>row.event===event)?.released); + } +}); diff --git a/tests/kull-whole-original-owner-guard.test.ts b/tests/kull-whole-original-owner-guard.test.ts new file mode 100644 index 00000000..114dc589 --- /dev/null +++ b/tests/kull-whole-original-owner-guard.test.ts @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { handLimit, viewGame, type Game } from '../game/engine'; +import { act, advance, clean, createKullRicheseSemutaFixture, inventory, player } from './fixture-kull-richese-semuta'; + +/** Real Basic setup, cache purchase and active Emperor shipment window. + * Only an existing physical Truthtrance is reassigned to Richese's hand. */ +function source() { + const f=createKullRicheseSemutaFixture(), game=structuredClone(f.game); + const zones=[game.deck,game.discard,...(game.richeseCache?[game.richeseCache]:[]), + ...(game.richeseRemoved?[game.richeseRemoved]:[]),...game.players.map(seat=>seat.hand)]; + const zone=zones.find(cards=>cards.some(card=>card.effect==='truthtrance')); assert.ok(zone); + const [truth]=zone.splice(zone.findIndex(card=>card.effect==='truthtrance'),1); + assert.ok(player(game,'richese').hand.length!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; +} +function answer(f:ReturnType, value:'yes'|'no') { + let game=act(f.game,'richese',{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 source=JSON.stringify(['karamaShipment',game.turn,'emperor','original']); + game=act(game,'richese',{type:'truthAsk',question:{kind:'turnActions',target:'emperor',turn:game.turn, + mixed:{kind:'action',action:{kind:'windowCardPlay',effect:'karamaShipment',source}}}}); + assert.ok(viewGame(game,'emperor').truthTurnActionAnswers?.includes(value)); + game=act(game,'emperor',{type:'truthAnswer',answer:value}); + const promise=game.turnActionPromises!.at(-1); assert.ok(promise); + if (game.pendingTreacheryDiscard) game=passDiscard(game); + return {game,event:promise.event}; +} + +void test('an original Karama forbidden by the actor’s whole No cannot open Kull',()=>{ + const f=source(); const {game:initialGame,event}=answer(f,'no'); let game=initialGame; + const before=structuredClone(game); + assert.throws(()=>act(game,'emperor',{type:'card',mode:'shipment',card:f.karama,target:'emperor'})); + assert.deepEqual(game,before); + assert.equal(game.pendingKull ?? null,null); + game=act(game,'emperor',{type:'ship',territory:'polar_sink',sector:0,amount:1,elite:0,allyPayment:0}); + game=advance(game,clean); + assert.ok(!game.turnActionPromises!.find(row=>row.event===event)?.released); + assert.deepEqual(inventory(game),f.originalCards); +}); + +void test('an original printed Karama required by the actor’s whole Yes stays legal through Kull decline',()=>{ + const f=source(); const {game:initialGame,event}=answer(f,'yes'); let game=initialGame; + game=act(game,'emperor',{type:'card',mode:'shipment',card:f.karama,target:'emperor'}); + const opportunity=viewGame(game,'choam').kullReaction; assert.ok(opportunity); + game=act(game,'choam',{type:'kullDecision',event:opportunity.event,decline:true}); + if (game.pendingTreacheryDiscard) game=passDiscard(game); + game=advance(game,clean); + assert.equal(game.turnActionPromises!.find(row=>row.event===event)?.fulfilled,true); + assert.equal(game.karamaShipping?.card,f.karama); + assert.deepEqual(inventory(game),f.originalCards); +}); diff --git a/tests/kull-whole-turn-scope-controls.test.tsx b/tests/kull-whole-turn-scope-controls.test.tsx new file mode 100644 index 00000000..4c8fec8b --- /dev/null +++ b/tests/kull-whole-turn-scope-controls.test.tsx @@ -0,0 +1,100 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { registerHooks } from 'node:module'; +import { readFileSync } from 'node:fs'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { handLimit, viewGame, type Action, type Game, type GameView } from '../game/engine'; +import { turnActionPromiseModeSupported } from '../game/turn-action-promises'; +import { createKullBribeFixture } from './fixture-kull-bribe'; +import { act, advance, clean, createKullRicheseSemutaFixture, inventory, player } from './fixture-kull-richese-semuta'; + +const aliases = registerHooks({ + resolve(specifier, context, next) { + return next(specifier === 'next/image' ? 'vinext/shims/image' : + specifier === 'next/link' ? 'vinext/shims/link' : specifier, context); + }, + load(url, context, next) { + if (!url.endsWith('.module.css')) return next(url, context); + const css = readFileSync(new URL(url), 'utf8'); + const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]])); + return {format:'module', source:`export default ${JSON.stringify(classes)}`, shortCircuit:true}; + }, +}); +const {GameTable} = await import('../components/game-table'); +const {Truthtrance} = await import('../components/truthtrance'); +const {TurnActionGuideButton} = await import('../components/turn-action-promises'); +aliases.deregister(); + +const table = (game:GameView) => renderToStaticMarkup(createElement(GameTable, + {game,busy:false,send:async () => {},onExit() {}})); +const truth = (game:GameView) => renderToStaticMarkup(createElement(Truthtrance, + {game,busy:false,act() {}})); + +void test('Basic printed Kull admits whole-turn questions without opening Advanced or optional modules',()=>{ + const classic = {advanced:false, expansions:['choam','ix'], kullPreview:true, + players:[{faction:'choam'},{faction:'emperor'},{faction:'guild'}]}; + const richese = {...classic, semutaPreview:true, + players:[...classic.players, {faction:'richese'}]}; + assert.equal(turnActionPromiseModeSupported(classic),true); + assert.equal(turnActionPromiseModeSupported(richese),true); + assert.equal(turnActionPromiseModeSupported({...richese,kullPreview:false}),true); + assert.equal(turnActionPromiseModeSupported({...richese,advanced:true}),false); + assert.equal(turnActionPromiseModeSupported({...classic,expansions:['choam']}),false); + assert.equal(turnActionPromiseModeSupported({...richese,players:[...richese.players,{faction:'richese'}]}),false); + for (const flag of ['nexusKullPreview','richeseBetrayalPreview','nexusCards','homeworlds', + 'leaderSkills','techTokens','strongholdCards','discoveryEnabled','ecazTreachery'] as const) + assert.equal(turnActionPromiseModeSupported({...richese,[flag]:true}),false,flag); +}); + +/** Both sources use genuine Basic setup and phases. In the Richese case only + * one original physical Truthtrance is moved to Harkonnen's hand; the native + * Richese cache purchase and all subsequent actions remain real. */ +function source(richese:boolean): {game:Game; asker:string; card:string; originalCards:string[]} { + if (!richese) { + const f = createKullBribeFixture(); + return {game:structuredClone(f.game),asker:f.asker,card:f.truth,originalCards:f.originalCards}; + } + const f = createKullRicheseSemutaFixture(); + const game = structuredClone(f.game); + const zones = [game.deck,game.discard,...(game.richeseCache?[game.richeseCache]:[]), + ...(game.richeseRemoved?[game.richeseRemoved]:[]),...game.players.map(seat=>seat.hand)]; + const zone=zones.find(cards=>cards.some(card=>card.effect==='truthtrance')); assert.ok(zone); + const [card]=zone.splice(zone.findIndex(row=>row.effect==='truthtrance'),1); + const asker=player(game,'harkonnen'); assert.ok(asker.hand.length < handLimit(asker)); asker.hand.push(card); + assert.deepEqual(inventory(game),f.originalCards); + return {game,asker:'harkonnen',card:card.id,originalCards:f.originalCards}; +} + +for (const richese of [false,true]) void test(`Basic Kull ${richese?'with':'without'} Richese renders and executes a whole-answer guide`,()=>{ + const f=source(richese); let game=act(f.game,f.asker,{type:'card',card:f.card}); + 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 asker=viewGame(game,f.asker); + assert.ok(asker.truthTurnActionTargets.includes('emperor')); + assert.match(truth(asker),/Combine printed card plays, native battle powers and this turn’s other actions/); + assert.match(table(asker),/Combine printed card plays, native battle powers and this turn’s other actions/); + game=act(game,f.asker,{type:'truthAsk',question:{kind:'turnActions',target:'emperor',turn:game.turn, + mixed:{kind:'and',terms:[ + {kind:'action',action:{kind:'payment',recipient:'choam',minimum:1}}, + {kind:'action',action:{kind:'ship',territory:'polar_sink',minimum:1}}, + ]}}}); + const respondent=viewGame(game,'emperor'); + assert.ok(respondent.truthTurnActionAnswers?.includes('yes')); + assert.ok(respondent.truthTurnActionAnswers?.includes('no')); + assert.equal(viewGame(game,f.asker).truthTurnActionAnswers,null); + assert.match(truth(respondent),/Answer Yes/); assert.match(truth(respondent),/Answer No/); + assert.match(table(respondent),/Answer Yes/); assert.match(table(respondent),/Answer No/); + game=act(game,'emperor',{type:'truthAnswer',answer:'yes'}); + game=advance(game,clean); + const own=viewGame(game,'emperor'), sent:Action[]=[]; assert.ok(own.turnActionGuide); + assert.match(table(own),/while honoring your whole answer/); + const button=TurnActionGuideButton({game:own,busy:false,act(action) {sent.push(action);}}); + assert.ok(button); assert.equal(button.props.disabled,false); button.props.onClick(); + assert.deepEqual(sent,[own.turnActionGuide.action]); + game=act(game,'emperor',sent[0]); + assert.ok(game.turnActionPromises?.some(row=>row.player==='emperor' && !row.released)); + assert.deepEqual(inventory(game),f.originalCards); +}); diff --git a/tests/public-choam-kull-controls.test.tsx b/tests/public-choam-kull-controls.test.tsx index 367dd6c8..f36088bb 100644 --- a/tests/public-choam-kull-controls.test.tsx +++ b/tests/public-choam-kull-controls.test.tsx @@ -31,7 +31,7 @@ void test('public host lobby shows the explicitly selected Kull preview and its assert.equal(host.lobbyModules?.kullPreview,true); assert.match(html,/Kull Wahad · Basic development preview/); assert.match(html,/Printed Kull is enabled by default/); - assert.match(html,/Combined turn-action Truthtrance questions are unavailable/); + assert.match(html,/Combined turn-action questions are available/); assert.match(html,/Unfunded non-CHOAM Karama-dependent bids remain deferred/); assert.match(html,/Some Kull reactions may be unavailable in complex response windows/); const off = act(f.lobby,f.lobby.host,{type:'moduleOption',module:'kullPreview',enabled:false}); @@ -39,7 +39,7 @@ void test('public host lobby shows the explicitly selected Kull preview and its assert.equal(unselected.lobbyModules?.kullPreview,false); assert.match(table(unselected),/Kull Wahad · Basic development preview/); assert.match(table(unselected),/The host has turned off the printed Kull preview/); - assert.doesNotMatch(table(unselected),/Combined turn-action Truthtrance questions are unavailable/); + assert.doesNotMatch(table(unselected),/Combined turn-action questions are available/); const nonhost = table(viewGame(f.lobby,f.actor)); assert.match(nonhost,/disabled=""[^>]*type="checkbox"|type="checkbox"[^>]*disabled=""/); });