Continue Kull across paid sale responses
Verify and publish checkpoint container / container (push) Successful in 10m57s
Verify and publish checkpoint container / container (push) Successful in 10m57s
This commit is contained in:
1 parent
3d2470b7f8
commit
e7520e8679
9 files changed
+487
-18
No files matched your search
@@ -1,5 +1,29 @@
|
||||
# Kull Wahad: source limits and Karama interruption design
|
||||
|
||||
## Basic paid-sale responses and Kull — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** The supported Basic Richese/Semuta profile now also
|
||||
retains a paid ordinary or cache sale when Kull interrupts a Karama cancellation
|
||||
of Emperor's auction income. The same continuation supports cancellation of an
|
||||
Ixian ally's requested replacement after an ordinary sale; the adopted Ixian
|
||||
Technology rule excludes the Richese cache. The original sale, recipient and
|
||||
replacement request remain in place through Kull success or its distinct
|
||||
counter, with Semuta offered on each supported physical discard. The native
|
||||
auction continuation remains responsible for income, replacement and later
|
||||
bonus effects, without a forecasted draw or second payment.
|
||||
|
||||
A validated completed normal-sale receipt no longer requires the buyer to
|
||||
retain the already-paid bid in their purse when offering or using Kull. The
|
||||
existing fence for an unfunded, still-unpaid Karama-dependent overbid remains.
|
||||
Cases with unproved whole-turn answer continuations, other sale parents,
|
||||
Advanced and optional combinations remain separate. The online order remains
|
||||
provisional. Genuine Basic fixtures use selected unopened cards and preserve
|
||||
the native sales; see [Emperor income](../tests/kull-richese-emperor-income-runtime.test.ts)
|
||||
and [Ixian replacement](../tests/kull-richese-ix-replacement-runtime.test.ts).
|
||||
[Funding cases](../tests/kull-paid-auction-funding.test.ts) exercise both paid
|
||||
buyers with empty purses and an unfunded unpaid overbid. The earlier Semuta-only
|
||||
Ixian fixture now explicitly declines the newly available Kull opportunity.
|
||||
|
||||
## Basic paid auction bonus and Kull — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** In the supported Basic Richese/Semuta and printed
|
||||
|
||||
@@ -17,6 +17,16 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Basic paid-sale responses with Kull and Semuta — 29 October. Prototyped / Partial.**
|
||||
Emperor income cancellation after a normal or Richese cache sale and Ixian
|
||||
ally replacement cancellation after a normal sale can now retain the original
|
||||
paid sale through supported Kull and Semuta reactions. A validated completed
|
||||
sale no longer imposes a second bid funding requirement; still-unpaid unfunded
|
||||
overbids remain deferred. Ten related files pass 86 cases, including the
|
||||
updated Semuta-only fixture; typecheck, lint and the production build pass.
|
||||
Other profiles and whole-answer continuations remain
|
||||
unfinished. [Scope](CHOAM_KULL_DESIGN.md#basic-paid-sale-responses-and-kull--29-october-2026).
|
||||
|
||||
**Basic paid-auction bonus with Kull and Semuta — 29 October. Prototyped / Partial.**
|
||||
Kull can interrupt a Karama cancellation of Harkonnen's bonus after a supported
|
||||
paid normal or Richese cache sale. The original sale and bought card remain
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# Rule decision index
|
||||
|
||||
## Basic paid-sale responses and Kull — 29 October 2026
|
||||
|
||||
The supported Basic printed-Kull and Semuta profile retains Emperor income
|
||||
responses after normal or cache sales and Ixian ally replacement responses
|
||||
after ordinary sales. Validated completed sales do not create a second bid
|
||||
funding requirement; unfunded unpaid overbids remain deferred. Existing
|
||||
online response ordering is provisional.
|
||||
[Scope](CHOAM_KULL_DESIGN.md#basic-paid-sale-responses-and-kull--29-october-2026).
|
||||
|
||||
## Basic paid auction bonus and Kull — 29 October 2026
|
||||
|
||||
The supported Basic Richese/Semuta profile now retains a paid ordinary or
|
||||
|
||||
+35
-15
@@ -1620,7 +1620,7 @@ export type Game = {
|
||||
allianceReceipt?: true;
|
||||
/** New Basic Kull attempts retain whole-answer order and progress. */
|
||||
turnActionReceipt?: true;
|
||||
/** A Basic bonus cancellation retains its already-paid auction parent. */
|
||||
/** A Basic sale cancellation retains its already-paid auction parent. */
|
||||
auctionReceipt?: true;
|
||||
stage: 'offer' | 'counter';
|
||||
selection?: { source: 'printed' | 'nexus'; card: string; nexusEvent?: string };
|
||||
@@ -6940,7 +6940,7 @@ function kullSemutaParentSupported(g: Game, frame?: NonNullable<Game['pendingKul
|
||||
!frame.resume.decision && !frame.resume.pendingKarama && !frame.resume.phaseOpening;
|
||||
let revivalSupported = false;
|
||||
let guildSupported = false;
|
||||
let bonusSupported = false;
|
||||
let saleSupported = false;
|
||||
if (nativeResponse && frame && use?.kind === 'cancel') {
|
||||
// Success has just installed this very Kull restriction. Validate the
|
||||
// suspended source as it stood before that restriction, without changing
|
||||
@@ -6952,12 +6952,19 @@ function kullSemutaParentSupported(g: Game, frame?: NonNullable<Game['pendingKul
|
||||
revivalSupported = !!g.pendingRevival && semutaRevivalKaramaResponse(source, use.response, frame.owner);
|
||||
guildSupported = (!!g.pendingShipment || !!g.pendingGuildTransport) &&
|
||||
semutaGuildShipmentKaramaResponse(source, use.response);
|
||||
bonusSupported = frame.auctionReceipt === true && use.response.kind === 'harkonnenBonus' &&
|
||||
!!g.currentAuctionSale && (g.currentAuctionSale.origin === 'normal'
|
||||
const paidSale = frame.auctionReceipt === true && !!g.currentAuctionSale && (g.currentAuctionSale.origin === 'normal'
|
||||
? !!g.auction && !g.richeseAuction
|
||||
: g.currentAuctionSale.origin === 'cache' && !!g.richeseAuction &&
|
||||
g.richeseAuction.source === 'cache' && !g.auction) &&
|
||||
semutaBonusNoFieldTraitorResponse(source, use.response, frame.owner);
|
||||
g.richeseAuction.source === 'cache' && !g.auction);
|
||||
if (paidSale && use.response.kind === 'harkonnenBonus')
|
||||
saleSupported = semutaBonusNoFieldTraitorResponse(source, use.response, frame.owner);
|
||||
else if (paidSale && use.response.kind === 'ixAllyCard')
|
||||
saleSupported = semutaMarketGrantKaramaResponse(source, use.response, frame.owner);
|
||||
else if (paidSale && use.response.kind === 'emperorIncome' && !use.response.source &&
|
||||
semutaIncomeKaramaResponse(source, use.response)) {
|
||||
validateKaramaUse(source, getPlayer(source, frame.owner), use);
|
||||
saleSupported = true;
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof RuleError)) throw error;
|
||||
}
|
||||
@@ -6971,13 +6978,14 @@ function kullSemutaParentSupported(g: Game, frame?: NonNullable<Game['pendingKul
|
||||
JSON.stringify(use.response) === JSON.stringify(frame.resume.response) &&
|
||||
!frame.resume.decision && !frame.resume.pendingKarama && !frame.resume.phaseOpening;
|
||||
return battleSupported && !g.truthtrance && !g.pendingRicheseGift && !g.pendingNullentropy &&
|
||||
!g.pendingAmbassador && !g.pendingIxAlly && (!g.pendingRevival || revivalSupported) &&
|
||||
!g.pendingAmbassador && (!g.pendingIxAlly || saleSupported && use?.kind === 'cancel' &&
|
||||
use.response.kind === 'ixAllyCard') && (!g.pendingRevival || revivalSupported) &&
|
||||
(!g.pendingShipment || guildSupported) && (!g.pendingGuildTransport || guildSupported) &&
|
||||
!g.pendingHomeworldShipment && !g.pendingTerrorEntry &&
|
||||
!g.pendingEcazPlacement && !g.pendingMoritaniPlacement &&
|
||||
!g.pendingArrivalOverlap && !g.pendingArrivalReaction?.length && !g.pendingExchange &&
|
||||
!g.pendingRichesePurchaseIncome && !g.pendingChoamMarketGhola && (!g.currentAuctionSale || bonusSupported) &&
|
||||
!g.choamMarket && (!g.richeseAuction || bonusSupported) && !g.pendingFremenMove && !g.pendingIxMove &&
|
||||
!g.pendingRichesePurchaseIncome && !g.pendingChoamMarketGhola && (!g.currentAuctionSale || saleSupported) &&
|
||||
!g.choamMarket && (!g.richeseAuction || saleSupported) && !g.pendingFremenMove && !g.pendingIxMove &&
|
||||
!g.pendingMobileMove && !g.pendingChoamMove && !g.ornithopter && !g.ecologicalStorm &&
|
||||
!g.summonedWorm && !g.wormRides.length;
|
||||
}
|
||||
@@ -7951,7 +7959,10 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
(!g.pendingNullentropy || continuation?.kind === 'karamaRicheseGiftDiscard') &&
|
||||
(!g.pendingAmbassador || box || truth || ambassadorTerror || ambassadorCost || continuation?.kind === 'karamaRicheseGiftDiscard') &&
|
||||
(!g.pendingIxAlly || box || truth || continuation?.kind === 'karamaRicheseGiftDiscard' ||
|
||||
continuation?.kind === 'karamaMarketDiscard' && continuation.use.response.kind === 'ixAllyCard'),
|
||||
continuation?.kind === 'karamaMarketDiscard' && continuation.use.response.kind === 'ixAllyCard' ||
|
||||
(continuation?.kind === 'kullSuccessDiscard' || continuation?.kind === 'kullCounterDiscard') &&
|
||||
continuation.frame.auctionReceipt === true && continuation.frame.intent.kind === 'ordinary' &&
|
||||
continuation.frame.intent.use.kind === 'cancel' && continuation.frame.intent.use.response.kind === 'ixAllyCard'),
|
||||
'The saved discard does not match its current continuation.',
|
||||
);
|
||||
const ids = new Set<string>();
|
||||
@@ -19231,8 +19242,17 @@ function canonicalKullCard(card: Card): boolean {
|
||||
return !!expected && card.name === expected.name && card.kind === expected.kind &&
|
||||
card.effect === expected.effect;
|
||||
}
|
||||
/** A settled normal sale is not an outstanding bid. Validate its native
|
||||
* response and physical auction receipt before exempting it from funding. */
|
||||
function kullPaidAuctionSale(g: Game, player: Player): boolean {
|
||||
const sale = g.currentAuctionSale, auction = g.auction, response = g.response;
|
||||
if (!sale || sale.origin !== 'normal' || !auction || auction.bidder !== player.id ||
|
||||
sale.winner !== player.id || sale.amount !== auction.bid || !response) return false;
|
||||
try { return !!validateAuctionContinuationCancellation(g, response); }
|
||||
catch (error) { if (error instanceof RuleError) return false; throw error; }
|
||||
}
|
||||
function kullCostRetainsAuctionPayment(g: Game, p: Player, card: Card, physical: readonly Card[]): boolean {
|
||||
return !g.auction || g.auction.bidder !== p.id || auctionSpicePaymentFunded(g, p) ||
|
||||
return !g.auction || g.auction.bidder !== p.id || kullPaidAuctionSale(g, p) || auctionSpicePaymentFunded(g, p) ||
|
||||
p.hand.some(held => held.id !== card.id && canonicalKullCard(held) &&
|
||||
physical.reduce((count, candidate) => count + Number(candidate.id === held.id), 0) === 1 &&
|
||||
!karamaSpendingBlock(g, p, held, 'auction-free-purchase'));
|
||||
@@ -19429,8 +19449,8 @@ function kullIntegrity(g: Game): void {
|
||||
requireRule(frame.auctionReceipt === undefined || frame.auctionReceipt === true &&
|
||||
!g.advanced && g.semutaPreview === true && !!g.currentAuctionSale &&
|
||||
frame.intent.kind === 'ordinary' && frame.intent.use.kind === 'cancel' &&
|
||||
frame.intent.use.response.kind === 'harkonnenBonus',
|
||||
'The Kull attempt has lost its original paid auction bonus.');
|
||||
['harkonnenBonus','emperorIncome','ixAllyCard'].includes(frame.intent.use.response.kind),
|
||||
'The Kull attempt has lost its original paid auction response.');
|
||||
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]),
|
||||
@@ -19498,7 +19518,7 @@ function beginKullAttempt(g: Game, p: Player, card: Card, intent: KullFrame['int
|
||||
'Kull preview cannot interrupt this unproven Truthtrance cancellation continuation.');
|
||||
// This composition is user-deferred. The guard is private and independent
|
||||
// of CHOAM's hidden hand; never accept a transaction needing a made-up remedy.
|
||||
requireRule(!g.auction || g.auction.bidder !== p.id ||
|
||||
requireRule(!g.auction || g.auction.bidder !== p.id || kullPaidAuctionSale(g, p) ||
|
||||
g.auction.bid <= p.spice + (g.auction.allyPayment ?? 0),
|
||||
'Kull preview defers Karama activation while your winning overbid cannot be paid in spice.');
|
||||
const resume: KullFrame['resume'] = {
|
||||
@@ -19524,7 +19544,7 @@ function beginKullAttempt(g: Game, p: Player, card: Card, intent: KullFrame['int
|
||||
...(turnActionPromiseModeSupported(g) && g.turnActionPromises?.length
|
||||
? { turnActionReceipt: true as const } : {}),
|
||||
...(g.semutaPreview && !g.advanced && !!g.currentAuctionSale && intent.kind === 'ordinary' &&
|
||||
intent.use.kind === 'cancel' && intent.use.response.kind === 'harkonnenBonus'
|
||||
intent.use.kind === 'cancel' && ['harkonnenBonus','emperorIncome','ixAllyCard'].includes(intent.use.response.kind)
|
||||
? { auctionReceipt: true as const } : {}),
|
||||
worthless: structuredClone(g.pendingChoamWorthless ?? null), signature: '',
|
||||
};
|
||||
|
||||
+2
-2
@@ -4863,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. 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. Kull and Semuta also retain a paid ordinary or cache sale when Karama targets Harkonnen’s bonus. 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. Kull and Semuta also retain a paid ordinary or cache sale when Karama targets Harkonnen’s bonus or Emperor income, and an ordinary sale when it targets an Ixian ally’s replacement. 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.',
|
||||
@@ -4878,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. 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-richese-cache-cancel-runtime.test.ts', 'tests/kull-richese-normal-bonus-runtime.test.ts', '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-richese-cache-cancel-runtime.test.ts', 'tests/kull-richese-normal-bonus-runtime.test.ts', 'tests/kull-richese-emperor-income-runtime.test.ts', 'tests/kull-richese-ix-replacement-runtime.test.ts', 'tests/kull-paid-auction-funding.test.ts', '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'] },
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Game } from '../game/engine';
|
||||
import { act, advance, clean, createKullRicheseSemutaFixture, player } from './fixture-kull-richese-semuta';
|
||||
|
||||
/** These use the public Basic Kull/Richese fixture and its actual purchase of
|
||||
* Semuta. On turn two, select the unopened first ordinary lot so CHOAM cannot
|
||||
* fund an alleged second payment with a newly drawn Karama. No live purse,
|
||||
* hand, bidding state, response or pending transaction is staged. */
|
||||
function auctionStart(winner:'choam'|'harkonnen') {
|
||||
const f=createKullRicheseSemutaFixture();
|
||||
let game=advance(f.game,state=>state.turn===2 && state.phase===3 && !state.response &&
|
||||
!state.phaseOpening && state.decision?.kind==='richeseDeclaration');
|
||||
const round=game.richeseBidding; assert.ok(round);
|
||||
const nonKarama=game.deck.findIndex(card=>card.effect!=='karama'); assert.ok(nonKarama>=0);
|
||||
game.deck.unshift(game.deck.splice(nonKarama,1)[0]);
|
||||
game=act(game,'richese',{type:'decision',event:round.event,position:'last'});
|
||||
game=advance(game,state=>state.phase===3 && !!state.auction &&
|
||||
state.auction.active===winner && state.auction.bid===0 && clean(state));
|
||||
assert.equal(game.auction?.cards[game.auction.index].effect==='karama',false);
|
||||
assert.ok(player(game,winner).spice>0);
|
||||
assert.ok(player(game,'choam').hand.some(card=>card.id===f.kull));
|
||||
assert.ok(player(game,'harkonnen').hand.some(card=>card.id===f.counter));
|
||||
return {...f,game};
|
||||
}
|
||||
|
||||
function paidIncome(winner:'choam'|'harkonnen') {
|
||||
const f=auctionStart(winner); let game=f.game;
|
||||
const paid=player(game,winner).spice, lot=game.auction!.cards[game.auction!.index].id;
|
||||
game=act(game,winner,{type:'bid',amount:paid,allyPayment:0});
|
||||
game=advance(game,state=>state.response?.kind==='emperorIncome');
|
||||
assert.equal(game.currentAuctionSale?.origin,'normal');
|
||||
assert.equal(game.currentAuctionSale?.winner,winner);
|
||||
assert.equal(game.currentAuctionSale?.amount,paid);
|
||||
assert.equal(player(game,winner).spice,0);
|
||||
assert.ok(player(game,winner).hand.some(card=>card.id===lot));
|
||||
assert.ok(player(game,'harkonnen').hand.some(card=>card.id===f.counter));
|
||||
return {...f,game,paid,lot};
|
||||
}
|
||||
|
||||
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<game.players.length;count++) {
|
||||
const reaction=game.pendingTreacheryDiscard!.reaction; assert.ok(reaction?.stage==='offer');
|
||||
const seat=game.players.find(row=>!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('CHOAM may use Kull after spending its entire winning bid',()=>{
|
||||
const f=paidIncome('choam'); let game=f.game;
|
||||
const emperorBefore=player(game,'emperor').spice;
|
||||
assert.ok(!player(game,'choam').hand.some(card=>card.effect==='karama'));
|
||||
game=act(game,'harkonnen',{type:'card',mode:'cancel',card:f.counter});
|
||||
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});
|
||||
game=advance(game,state=>!!state.pendingTreacheryDiscard);
|
||||
game=passDiscard(game);
|
||||
assert.equal(player(game,'choam').spice,0);
|
||||
assert.equal(player(game,'emperor').spice,emperorBefore+f.paid);
|
||||
assert.ok(player(game,'choam').hand.some(card=>card.id===f.lot));
|
||||
assert.ok(player(game,'harkonnen').hand.some(card=>card.id===f.counter));
|
||||
});
|
||||
|
||||
void test('a winner may attempt to cancel Emperor income after spending its entire bid',()=>{
|
||||
const f=paidIncome('harkonnen'); let game=f.game;
|
||||
const emperorBefore=player(game,'emperor').spice;
|
||||
assert.ok(viewGame(game,'harkonnen').responseControls?.cancelCards.includes(f.counter));
|
||||
game=act(game,'harkonnen',{type:'card',mode:'cancel',card:f.counter});
|
||||
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,decline:true});
|
||||
game=advance(game,state=>!!state.pendingTreacheryDiscard);
|
||||
game=passDiscard(game);
|
||||
assert.equal(player(game,'harkonnen').spice,0);
|
||||
assert.equal(player(game,'emperor').spice,emperorBefore);
|
||||
assert.ok(player(game,'harkonnen').hand.some(card=>card.id===f.lot));
|
||||
assert.ok(!player(game,'harkonnen').hand.some(card=>card.id===f.counter));
|
||||
});
|
||||
|
||||
void test('an unpaid non-CHOAM Karama-dependent overbid remains rejected',()=>{
|
||||
const f=auctionStart('harkonnen'), before=structuredClone(f.game);
|
||||
const amount=player(f.game,'harkonnen').spice+1;
|
||||
assert.throws(()=>act(f.game,'harkonnen',{type:'bid',amount,allyPayment:0}),/unfunded Karama-dependent overbid/);
|
||||
assert.deepEqual(f.game,before);
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Game } from '../game/engine';
|
||||
import { act, advance, clean, createKullRicheseSemutaFixture, player } from './fixture-kull-richese-semuta';
|
||||
|
||||
/** The live sold lot remains an auction reference; its physical card is in
|
||||
* the buyer's hand. Later lots remain in the original auction custody. */
|
||||
function inventory(game: Game): string[] {
|
||||
const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []),
|
||||
...(game.richeseRemoved ?? []), ...game.players.flatMap(seat => seat.hand),
|
||||
...(game.auction?.cards.slice(game.auction.index + Number(!!game.currentAuctionSale)) ?? [])]
|
||||
.map(card => card.id).sort();
|
||||
assert.equal(new Set(cards).size, cards.length);
|
||||
return cards;
|
||||
}
|
||||
|
||||
/** Genuine Basic setup, the real first-turn Richese Semuta purchase, and a
|
||||
* paid turn-two normal lot. The existing fixture selects unopened cards and
|
||||
* Spice order; no live hand, wallet, payment, response or promise is staged. */
|
||||
function source() {
|
||||
const f = createKullRicheseSemutaFixture();
|
||||
let game = advance(f.game, state => state.turn === 2 && state.phase === 3 &&
|
||||
!state.response && !state.phaseOpening && state.decision?.kind === 'richeseDeclaration');
|
||||
const round = game.richeseBidding; assert.ok(round);
|
||||
game = act(game, 'richese', {type:'decision', event:round.event, position:'last'});
|
||||
game = advance(game, state => state.phase === 3 && !!state.auction &&
|
||||
state.auction.active === 'harkonnen' && state.auction.bid === 0 && clean(state));
|
||||
const index = game.auction!.index, lot = game.auction!.cards[index].id;
|
||||
const buyerSpice = player(game, 'harkonnen').spice;
|
||||
const emperorSpice = player(game, 'emperor').spice;
|
||||
game = act(game, 'harkonnen', {type:'bid', amount:1, allyPayment:0});
|
||||
game = advance(game, state => state.response?.kind === 'emperorIncome');
|
||||
assert.equal(game.response?.owner, 'emperor');
|
||||
assert.equal(game.currentAuctionSale?.origin, 'normal');
|
||||
assert.equal(game.currentAuctionSale?.winner, 'harkonnen');
|
||||
assert.equal(game.currentAuctionSale?.amount, 1);
|
||||
assert.equal(game.auction?.index, index);
|
||||
assert.ok(player(game, 'harkonnen').hand.some(card => card.id === lot));
|
||||
assert.equal(player(game, 'harkonnen').spice, buyerSpice - 1);
|
||||
assert.equal(player(game, 'emperor').spice, emperorSpice);
|
||||
assert.ok(player(game, 'choam').hand.some(card => card.id === f.kull));
|
||||
assert.ok(player(game, 'harkonnen').hand.some(card => card.id === f.counter));
|
||||
assert.ok(player(game, 'emperor').hand.some(card => card.id === f.karama));
|
||||
assert.ok(player(game, 'richese').hand.some(card => card.id === f.semuta));
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
return {...f, game, index, lot, buyerSpice:buyerSpice - 1, emperorSpice};
|
||||
}
|
||||
|
||||
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 < game.players.length; count++) {
|
||||
const reaction = game.pendingTreacheryDiscard!.reaction; assert.ok(reaction?.stage === 'offer');
|
||||
const seat = game.players.find(row => !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 claimDiscard(game: Game, card: string): Game {
|
||||
const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction?.stage === 'offer');
|
||||
assert.ok(pending.batch.entries.some(entry => entry.card.id === card));
|
||||
assert.equal(viewGame(game, 'richese').semutaReaction?.canCommit, true);
|
||||
const event = pending.batch.event;
|
||||
game = act(game, 'richese', {type:'semutaCommit', event});
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
||||
game = act(game, 'richese', {type:'semutaSelect', event, card});
|
||||
assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event);
|
||||
return game;
|
||||
}
|
||||
|
||||
for (const outcome of ['decline','success','counter'] as const)
|
||||
for (const claim of [false,true])
|
||||
void test(`paid Emperor income: Kull ${outcome}, Semuta ${claim?'claim':'pass'}`, () => {
|
||||
const f = source(); let game = f.game;
|
||||
assert.ok(viewGame(game, 'harkonnen').responseControls?.cancelCards.includes(f.counter));
|
||||
game = act(game, 'harkonnen', {type:'card', mode:'cancel', card:f.counter});
|
||||
const reaction = viewGame(game, 'choam').kullReaction; assert.ok(reaction);
|
||||
assert.ok(reaction.plays.some(play => play.source === 'printed' && play.card.id === f.kull));
|
||||
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') {
|
||||
assert.ok(viewGame(game, 'emperor').responseControls?.cancelCards.includes(f.karama));
|
||||
game = act(game, 'emperor', {type:'card', mode:'cancel', card:f.karama});
|
||||
game = passDiscard(game);
|
||||
}
|
||||
if (outcome === 'success') game = advance(game, state => !!state.pendingTreacheryDiscard);
|
||||
const discarded = outcome === 'success' ? f.kull : f.counter;
|
||||
assert.ok(game.pendingTreacheryDiscard?.batch.entries.some(entry => entry.card.id === discarded));
|
||||
game = claim ? claimDiscard(game, discarded) : passDiscard(game);
|
||||
assert.equal(game.pendingKull ?? null, null);
|
||||
assert.ok(player(game, 'harkonnen').hand.some(card => card.id === f.lot));
|
||||
assert.equal(player(game, 'harkonnen').spice, f.buyerSpice);
|
||||
assert.equal(player(game, 'emperor').spice, f.emperorSpice + Number(outcome === 'success'));
|
||||
assert.equal(player(game, 'harkonnen').hand.some(card => card.id === f.counter), outcome === 'success');
|
||||
assert.equal(player(game, 'richese').hand.some(card => card.id === discarded), claim);
|
||||
assert.equal(player(game, 'richese').hand.some(card => card.id === f.semuta), !claim);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
/** The owner buys a real Richese cache card, so the native price goes to
|
||||
* Emperor. No auction, wallet, held card or pending response is staged. */
|
||||
function cacheSource() {
|
||||
const f = createKullRicheseSemutaFixture();
|
||||
let game = advance(f.game, state => state.turn === 2 && state.phase === 3 &&
|
||||
!state.response && !state.phaseOpening && state.decision?.kind === 'richeseDeclaration');
|
||||
const round = game.richeseBidding; assert.ok(round);
|
||||
game = act(game, 'richese', {type:'decision', event:round.event, position:'first'});
|
||||
for (let count = 0; game.response?.kind === 'richeseAuction' && count < game.players.length; count++) {
|
||||
const seat = game.players.find(row => !game.response!.passed.includes(row.id)); assert.ok(seat);
|
||||
game = act(game, seat.id, {type:'passResponse'});
|
||||
}
|
||||
assert.equal(game.decision?.kind, 'richeseCache');
|
||||
const card = game.richeseCache?.[0]; assert.ok(card);
|
||||
const buyerSpice = player(game, 'richese').spice;
|
||||
const emperorSpice = player(game, 'emperor').spice;
|
||||
game = act(game, 'richese', {type:'decision', event:game.richeseBidding!.event,
|
||||
card:card.id, method:'silent'});
|
||||
const lot = game.richeseAuction; assert.ok(lot?.method === 'silent');
|
||||
for (let count = 0; game.richeseAuction?.event === lot.event && !game.response && count < game.players.length; count++) {
|
||||
const current = game.richeseAuction!;
|
||||
const actor = current.eligible.find(id => !Object.hasOwn(current.sealed, id)); assert.ok(actor);
|
||||
game = act(game, actor, {type:'richeseBid', event:current.event,
|
||||
amount:actor === 'richese' ? 1 : 0, allyPayment:0});
|
||||
}
|
||||
assert.equal(game.response?.kind, 'emperorIncome');
|
||||
assert.equal(game.response?.owner, 'emperor');
|
||||
assert.equal(game.currentAuctionSale?.origin, 'cache');
|
||||
assert.equal(game.currentAuctionSale?.winner, 'richese');
|
||||
assert.equal(game.currentAuctionSale?.amount, 1);
|
||||
assert.ok(player(game, 'richese').hand.some(held => held.id === card.id));
|
||||
assert.equal(player(game, 'richese').spice, buyerSpice - 1);
|
||||
assert.equal(player(game, 'emperor').spice, emperorSpice);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
return {...f, game, purchased:card.id, buyerSpice:buyerSpice - 1, emperorSpice};
|
||||
}
|
||||
|
||||
for (const outcome of ['decline','success','counter'] as const)
|
||||
for (const claim of [false,true])
|
||||
void test(`paid Richese cache Emperor income: Kull ${outcome}, Semuta ${claim?'claim':'pass'}`, () => {
|
||||
const f = cacheSource(); let game = f.game;
|
||||
assert.ok(viewGame(game, 'harkonnen').responseControls?.cancelCards.includes(f.counter));
|
||||
game = act(game, 'harkonnen', {type:'card', mode:'cancel', card:f.counter});
|
||||
const reaction = viewGame(game, 'choam').kullReaction; assert.ok(reaction);
|
||||
assert.ok(reaction.plays.some(play => play.source === 'printed' && play.card.id === f.kull));
|
||||
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') {
|
||||
assert.ok(viewGame(game, 'emperor').responseControls?.cancelCards.includes(f.karama));
|
||||
game = act(game, 'emperor', {type:'card', mode:'cancel', card:f.karama});
|
||||
game = passDiscard(game);
|
||||
}
|
||||
if (outcome === 'success') game = advance(game, state => !!state.pendingTreacheryDiscard);
|
||||
const discarded = outcome === 'success' ? f.kull : f.counter;
|
||||
assert.ok(game.pendingTreacheryDiscard?.batch.entries.some(entry => entry.card.id === discarded));
|
||||
game = claim ? claimDiscard(game, discarded) : passDiscard(game);
|
||||
if (game.response?.kind === 'emperorIncome')
|
||||
game = advance(game, state => state.response?.kind !== 'emperorIncome' && !state.pendingTreacheryDiscard);
|
||||
assert.equal(game.pendingKull ?? null, null);
|
||||
assert.ok(player(game, 'richese').hand.some(card => card.id === f.purchased));
|
||||
assert.equal(player(game, 'richese').spice, f.buyerSpice);
|
||||
assert.equal(player(game, 'emperor').spice, f.emperorSpice + Number(outcome === 'success'));
|
||||
assert.equal(player(game, 'harkonnen').hand.some(card => card.id === f.counter), outcome === 'success');
|
||||
assert.equal(player(game, 'richese').hand.some(card => card.id === discarded), claim);
|
||||
assert.equal(player(game, 'richese').hand.some(card => card.id === f.semuta), !claim);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createGame, joinGame, newPlayer, viewGame } from '../game/engine';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import {
|
||||
actSemutaIxAllyCost as act, advanceSemutaIxAllyCost as advance,
|
||||
allowSemutaIxAllyCost as allow, finishSemutaIxAllyCost as finish,
|
||||
passSemutaIxAllyCost as pass, claimSemutaIxAllyCost as claim,
|
||||
semutaIxAllyCostPlayer as player, semutaIxAllyCostInventory as inventory,
|
||||
} from './fixture-semuta-ix-ally-cost';
|
||||
import { nextSemutaIxAllyCost } from './fixture-semuta-ix-ally-cost';
|
||||
|
||||
/** Public Basic R/C/I/F/G game. Only unopened initial deal and Spice Deck
|
||||
* positions are selected. CHOAM really buys Semuta; Ixians and Fremen form
|
||||
* a real turn-two Nexus alliance; Fremen pays for the current ordinary lot and
|
||||
* requests the native Ixian replacement. No resources or pending state are
|
||||
* installed. CHOAM holds both the initial Kull and purchased Semuta, so it can
|
||||
* recover another player's cost but cannot recover its own Kull discard. */
|
||||
function source() {
|
||||
let game=createGame('KULLIXALLY',newPlayer('richese','Richese','richese'),false,['choam','ix']);
|
||||
for (const id of ['choam','ixians','fremen','guild'] as const)
|
||||
joinGame(game,newPlayer(id,id,id));
|
||||
for (const seat of game.players) game=act(game,seat.id,{type:'ready'});
|
||||
game=act(game,'richese',{type:'start'});
|
||||
assert.equal(game.status,'setup'); assert.equal(game.kullPreview,true); assert.equal(game.semutaPreview,true);
|
||||
const original=inventory(game);
|
||||
const worthless=game.deck.find(card=>card.kind==='worthless' && card.id!=='ix-kull-wahad');
|
||||
const kull=game.deck.find(card=>card.id==='ix-kull-wahad');
|
||||
const shield=game.deck.find(card=>card.kind==='shield');
|
||||
const karamas=game.deck.filter(card=>card.effect==='karama').slice(0,2);
|
||||
assert.ok(worthless && kull && shield); assert.equal(karamas.length,2);
|
||||
const selected=[worthless,kull,shield,...karamas];
|
||||
selected.forEach((card,position)=>{
|
||||
const index=game.deck.findIndex(row=>row.id===card.id); assert.ok(index>=position);
|
||||
[game.deck[position],game.deck[index]]=[game.deck[index],game.deck[position]];
|
||||
});
|
||||
game=advance(game,state=>state.status==='playing');
|
||||
assert.ok(player(game,'choam').hand.some(card=>card.id===kull.id));
|
||||
assert.ok(player(game,'fremen').hand.some(card=>card.id===karamas[0].id));
|
||||
assert.ok(player(game,'guild').hand.some(card=>card.id===karamas[1].id));
|
||||
function selectSpice(faces:string[]) {
|
||||
faces.forEach((face,position)=>{
|
||||
const index=game.spiceDeck.findIndex((card,i)=>i>=position && (face==='worm'
|
||||
? 'worm' in card && !card.suppressed && !card.thumper && !card.greatMaker
|
||||
: 'territory' in card && card.territory===face && !card.discovery));
|
||||
assert.ok(index>=position); const [card]=game.spiceDeck.splice(index,1); game.spiceDeck.splice(position,0,card);
|
||||
});
|
||||
}
|
||||
selectSpice(['funeral_plain']);
|
||||
game=advance(game,state=>player(state,'choam').hand.some(card=>card.id===SEMUTA_DRUG_ID));
|
||||
game=advance(game,state=>state.turn===2 && state.phase===1 && !state.response && !state.phaseOpening &&
|
||||
!state.decision && !state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus && state.ready.length===0);
|
||||
selectSpice(['worm','the_great_flat']);
|
||||
game=advance(game,state=>state.turn===2 && state.phase===1 && !!state.nexus &&
|
||||
!state.spiceWindow && !state.spiceResolution && !state.response && !state.phaseOpening && !state.decision);
|
||||
game=act(game,'ixians',{type:'alliance',target:'fremen'});
|
||||
game=act(game,'fremen',{type:'alliance',target:'ixians'});
|
||||
game=advance(game,state=>state.turn===2 && state.phase===3 && !!state.auction &&
|
||||
state.auction.index===0 && state.auction.active==='fremen' && !state.response && !state.decision && !state.phaseOpening);
|
||||
const beforeSpice=player(game,'fremen').spice;
|
||||
game=act(game,'fremen',{type:'bid',amount:1,allyPayment:0});
|
||||
for (let count=0;game.decision?.kind!=='ixAllyCard' && count<100;count++) {
|
||||
if (!game.response && !game.phaseOpening && game.decision?.kind==='auctionPayment')
|
||||
game=act(game,'fremen',{type:'decision',karama:false});
|
||||
else { const next=nextSemutaIxAllyCost(game); assert.ok(next); game=act(game,next.actor,next.action); }
|
||||
}
|
||||
assert.equal(game.decision?.kind,'ixAllyCard');
|
||||
assert.equal(player(game,'fremen').spice,beforeSpice-1);
|
||||
assert.equal(game.currentAuctionSale?.origin,'normal');
|
||||
const pending=game.pendingIxAlly; assert.ok(pending);
|
||||
const delivered=pending.card, index=game.auction!.index;
|
||||
assert.ok(player(game,'fremen').hand.some(card=>card.id===delivered));
|
||||
game=act(game,'fremen',{type:'decision',accept:true});
|
||||
assert.equal(game.response?.kind,'ixAllyCard');
|
||||
assert.deepEqual(inventory(game),original);
|
||||
return {game,original,kull:kull.id,counter:karamas[0].id,karama:karamas[1].id,
|
||||
delivered,index,paidSpice:player(game,'fremen').spice, replacement:game.deck[0]?.id};
|
||||
}
|
||||
|
||||
for (const outcome of ['decline','success','counter'] as const)
|
||||
for (const claimed of [false,true]) {
|
||||
// CHOAM cannot recover its own successful Kull discard with Semuta.
|
||||
if (outcome==='success' && claimed) continue;
|
||||
void test(`paid Ixian ally replacement: Kull ${outcome}, Semuta ${claimed?'claim':'pass'}`,()=>{
|
||||
const f=source(); let game=f.game;
|
||||
assert.ok(viewGame(game,'guild').responseControls?.cancelCards.includes(f.karama));
|
||||
game=act(game,'guild',{type:'card',mode:'cancel',card:f.karama});
|
||||
const offer=viewGame(game,'choam').kullReaction; assert.ok(offer);
|
||||
assert.ok(offer.plays.some(row=>row.source==='printed' && row.card.id===f.kull));
|
||||
game=outcome==='decline' ? act(game,'choam',{type:'kullDecision',event:offer.event,decline:true}) :
|
||||
act(game,'choam',{type:'kullDecision',event:offer.event,source:'printed',card:f.kull});
|
||||
if (outcome==='counter') {
|
||||
assert.ok(viewGame(game,'fremen').responseControls?.cancelCards.includes(f.counter));
|
||||
game=act(game,'fremen',{type:'card',mode:'cancel',card:f.counter});
|
||||
game=pass(game);
|
||||
}
|
||||
if (outcome==='success') game=advance(game,state=>!!state.pendingTreacheryDiscard);
|
||||
const spent=outcome==='success'?f.kull:f.karama;
|
||||
assert.ok(game.pendingTreacheryDiscard?.batch.entries.some(entry=>entry.card.id===spent));
|
||||
if (claimed) {
|
||||
assert.equal(viewGame(game,'choam').semutaReaction?.canCommit,true);
|
||||
game=claim(game,'choam');
|
||||
} else game=pass(game);
|
||||
assert.equal(game.pendingKull ?? null,null);
|
||||
if (outcome==='success') {
|
||||
if (game.response?.kind==='ixAllyCard') game=allow(game);
|
||||
game=finish(game);
|
||||
assert.ok(player(game,'guild').hand.some(card=>card.id===f.karama));
|
||||
assert.ok(!player(game,'fremen').hand.some(card=>card.id===f.delivered));
|
||||
assert.ok(player(game,'fremen').hand.some(card=>card.id===f.replacement));
|
||||
assert.ok(game.discard.some(card=>card.id===f.delivered));
|
||||
} else {
|
||||
game=finish(game);
|
||||
assert.ok(player(game,'fremen').hand.some(card=>card.id===f.delivered));
|
||||
assert.ok(!player(game,'guild').hand.some(card=>card.id===f.karama));
|
||||
}
|
||||
assert.equal(player(game,'fremen').spice,f.paidSpice);
|
||||
assert.equal(game.auction?.index,f.index+1);
|
||||
assert.equal(player(game,'choam').hand.some(card=>card.id===f.karama),claimed);
|
||||
assert.equal(player(game,'choam').hand.some(card=>card.id===SEMUTA_DRUG_ID),!claimed);
|
||||
assert.deepEqual(inventory(game),f.original);
|
||||
});
|
||||
}
|
||||
|
||||
void test('Semuta may recover the distinct purchased-card discard after Kull preserves Ixian replacement',()=>{
|
||||
const f=source(); let game=act(f.game,'guild',{type:'card',mode:'cancel',card:f.karama});
|
||||
const offer=viewGame(game,'choam').kullReaction; assert.ok(offer);
|
||||
game=act(game,'choam',{type:'kullDecision',event:offer.event,source:'printed',card:f.kull});
|
||||
game=advance(game,state=>!!state.pendingTreacheryDiscard);
|
||||
game=pass(game);
|
||||
if (game.response?.kind==='ixAllyCard') game=allow(game);
|
||||
assert.ok(game.pendingTreacheryDiscard?.batch.entries.some(entry=>entry.card.id===f.delivered));
|
||||
assert.equal(viewGame(game,'choam').semutaReaction?.canCommit,true);
|
||||
game=claim(game,'choam');
|
||||
game=finish(game);
|
||||
assert.ok(player(game,'choam').hand.some(card=>card.id===f.delivered));
|
||||
assert.ok(player(game,'fremen').hand.some(card=>card.id===f.replacement));
|
||||
assert.equal(player(game,'fremen').spice,f.paidSpice);
|
||||
assert.deepEqual(inventory(game),f.original);
|
||||
});
|
||||
@@ -59,7 +59,14 @@ function nativeSource(f: SemutaIxAllyCostFixture) {
|
||||
assert.deepEqual(inventory(f.game), f.originalInventory); assert.equal(f.sourceActionCount, f.actions.length);
|
||||
}
|
||||
function open(f: SemutaIxAllyCostFixture) {
|
||||
const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard;
|
||||
let game = act(structuredClone(f.game), f.spender, f.action);
|
||||
// Basic Kull now gets its public pre-cost opportunity even when CHOAM has
|
||||
// no eligible card. Declining resumes this original Semuta-only scenario.
|
||||
if (game.pendingKull) {
|
||||
const choice = viewGame(game, 'choam').kullReaction; assert.ok(choice);
|
||||
game = act(game, 'choam', {type:'kullDecision', event:choice.event, decline:true});
|
||||
}
|
||||
const pending = game.pendingTreacheryDiscard;
|
||||
assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaMarketDiscard');
|
||||
if (pending.continuation.kind !== 'karamaMarketDiscard') throw Error('Use the original paid ally replacement cost');
|
||||
assert.equal(pending.batch.cause, 'karama:ixAllyCard'); assert.equal(pending.reaction?.stage, 'offer');
|
||||
|
||||
Reference in new issue
Block a user