Support printed Kull during market Ghola
Verify and publish checkpoint container / container (push) Successful in 11m25s

This commit is contained in:
Quintine committed 2026-10-29 08:37:50 +11:00
1 parent 79fc12c1c1
commit bcca08cac5
10 files changed
+232 -25

No files matched your search

+14
View File
@@ -1,5 +1,19 @@
# Kull Wahad: source limits and Karama interruption design
## Basic market Ghola and Kull — 29 October 2026
**Prototyped / Partial.** The default printed-Kull preview may be used with a
Basic Semuta reaction to Ghola during a CHOAM sale. If a player cancels the
resulting Tleilaxu income or the restored sale, Kull may decline, prevent the
Karama, or be countered. The physical Kull, counter and original Karama costs
retain their existing Semuta opportunities; the Ghola revival is not repeated
and the original income and sale resume or cancel once. The digital ordering
remains provisional. Nexus Kull, other optional modules and unrelated nested
market parents remain outside this scope.
The [native journeys](../tests/semuta-market-ghola-kull.test.ts) use public
setup and the real market, with conserved card and tank staging.
## Basic Tech Tokens — 29 October 2026
**Prototyped / Partial.** A fresh Basic lobby with native CHOAM and classic
+14 -2
View File
@@ -17,6 +17,17 @@ open mode gates.
## Current checkpoint and work
**Basic market Ghola with printed Kull — 29 October. Prototyped / Partial.**
The default printed-Kull preview now works with the Semuta market-Ghola
reaction. Kull decline, success and a distinct counter preserve the parked
income and original sale; their eligible physical costs retain Semuta offers.
Real market journeys also cover cancellation of the restored sale and all four
legal bot profiles, with conserved card and tank staging. Nine related files
pass 66 cases, including ten new runtime cases; type checking, lint and the
production build pass. Nexus Kull, other optional modules, unrelated nested
parents and deployed acceptance remain open. The ordering is provisional.
[Scope](CHOAM_KULL_DESIGN.md#basic-market-ghola-and-kull--29-october-2026).
**Basic Semuta after market Ghola — 29 October. Prototyped / Partial.** A
Ghola played during a clean CHOAM sale may offer its physical discard to
Semuta while preserving the committed revival, optional Tleilaxu income and
@@ -25,8 +36,9 @@ controls and minimal bot choices are reused. Six related files pass 37 cases,
including 14 changed-path cases; type checking, lint and production build
pass. Runtime journeys use public setup and the market, with conserved card
and tank staging. Old pending discards without a reaction retain their original
continuation. The default Kull preview must be turned off; other optional
modules, nested market parents and deployed acceptance remain open.
continuation. At this checkpoint the default Kull preview was excluded; the
later Kull scope above supersedes that boundary. Other optional modules, nested
market parents and deployed acceptance remain open.
[Scope](SEMUTA_DISCARD_CONTINUATIONS.md#basic-ghola-during-a-choam-sale--29-october-2026).
**Classic Basic bribes during pending choices — 29 October. Prototyped /
+5
View File
@@ -8,6 +8,11 @@ the Semuta offer, the original income response follows it, and the parked sale
continues once. This ordering is an online interpretation, not a publisher
clarification. [Scope](SEMUTA_DISCARD_CONTINUATIONS.md#basic-ghola-during-a-choam-sale--29-october-2026).
The same provisional ordering includes the default printed-Kull preview for
income and restored-sale cancellations; its separate physical costs use the
existing Semuta protocol. Nexus Kull and other optional compositions remain
outside this prototype. [Kull scope](CHOAM_KULL_DESIGN.md#basic-market-ghola-and-kull--29-october-2026).
## Classic Basic bribes during pending auction and revival — 29 October 2026
The publisher's base rulebook, printed p. 12, permits nonallied bribes but does
+6 -4
View File
@@ -12,10 +12,12 @@ resumes directly. No extra market, revival or card transfer is created.
This is a provisional online composition of the existing printed effects and
discard protocol, not a publisher timing ruling. Older saved Ghola discards
without a Semuta reaction retain their original continuation. The Kull preview,
enabled by default in this setup, must be turned off for this interaction;
other optional modules, nested market parents and complete faction acceptance
remain open.
without a Semuta reaction retain their original continuation. The default
printed-Kull preview may interrupt a Karama cancellation of the parked Ghola
income or the restored CHOAM sale. Kull's own discard, a distinct counter and
the original Karama cost use the existing Semuta offer; each preserves the
original income and sale. Nexus Kull, other optional modules, nested market
parents and complete faction acceptance remain open.
[Source and earlier scope](SEMUTA_SOURCE_UPDATE_20260907.md#ghola-and-conditional-native-income--24-october-2026).
## Basic Ixian ally replacement with independent Ecaz cards — 29 October 2026
+3 -1
View File
@@ -135,7 +135,9 @@ export function choamMarketGholaError(g: Game): string | null {
const heldRevival = g.homeworldRevivalReturn;
const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume,
g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume,
g.summonedWorm?.resume,
g.summonedWorm?.resume, g.pendingKull?.resume,
continuation && (continuation.kind === 'kullSuccessDiscard' ||
continuation.kind === 'kullCounterDiscard') ? continuation.frame.resume : null,
continuation && 'resume' in continuation ? continuation.resume : null,
heldRevival && ['choice', 'arrival'].includes(heldRevival.stage)
? { response: heldRevival.resumeResponse } : null];
+28 -6
View File
@@ -551,7 +551,7 @@ import {
import { transferDistrans } from './distrans';
import { distransAuctionBlock } from './distrans-timing';
import { SEMUTA_DRUG_ID, committedSemutaCandidates, resolveSemutaDrug, type FreshDiscardBatch, type SemutaContext } from './semuta-drug';
import { semutaMarketGholaSupported } from './semuta-market-ghola';
import { semutaMarketGholaProfile, semutaMarketGholaSupported } from './semuta-market-ghola';
import {
prepareRicheseGift,
transferRicheseGift,
@@ -6618,6 +6618,16 @@ function semutaKaramaRoleCost(g: Game, owner: string, card: Card): boolean {
return card.effect === 'karama' || (card.effect === 'truthtrance' &&
canUseAsKaramaRole(g, getPlayer(g, owner), card));
}
/** Match only the native income belonging to this already committed Ghola. */
function semutaMarketGholaIncome(g: Game, response: ResponseWindow): boolean {
const pending = g.pendingChoamMarketGhola;
if (!semutaMarketGholaProfile(g) || !bareBasicSemutaProfile(g) ||
!pending || pending.stage !== 'income' || response.kind !== 'revivalIncome' ||
response.source !== undefined || response.intent !== pending.event ||
response.recipient !== pending.player || response.amount !== 1 ||
!g.players.some(p => p.id === response.owner && p.faction === 'tleilaxu')) return false;
return choamMarketGholaError({...g, response}) === null;
}
/** Original paid income sources; cancellation never reverses the completed transaction. */
function semutaIncomeKaramaResponse(g: Game, response: ResponseWindow): boolean {
if (g.battle || g.pendingShipment || g.pendingGuildTransport ||
@@ -6629,7 +6639,8 @@ function semutaIncomeKaramaResponse(g: Game, response: ResponseWindow): boolean
if (response.kind === 'revivalIncome') {
// Ordinary returns and Ghola have already committed. Only their income
// remains; other producers retain their own unfinished parent contracts.
if (response.source !== undefined || g.pendingRevival || g.pendingChoamMarketGhola ||
if (response.source !== undefined || g.pendingRevival ||
(g.pendingChoamMarketGhola && !semutaMarketGholaIncome(g, response)) ||
(g.homeworldRevivalReturn && g.homeworldRevivalReturn.stage !== 'complete') ||
g.pendingHomeworldShipment || g.pendingSpiritualAdvisor ||
g.pendingArrivalOverlap || g.pendingArrivalReaction?.length ||
@@ -7029,6 +7040,7 @@ function kullSemutaParentSupported(g: Game, frame?: NonNullable<Game['pendingKul
let revivalSupported = false;
let guildSupported = false;
let saleSupported = false;
let marketSupported = 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
@@ -7040,6 +7052,12 @@ 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);
if (g.pendingChoamMarketGhola && semutaMarketGholaIncome(source, use.response)) {
validateKaramaUse(source, getPlayer(source, frame.owner), use);
marketSupported = true;
} else if (!g.pendingChoamMarketGhola && g.choamMarket && use.response.kind === 'choamSale') {
marketSupported = semutaMarketGrantKaramaResponse(source, use.response, frame.owner);
}
const paidSale = frame.auctionReceipt === true && !!g.currentAuctionSale && (g.currentAuctionSale.origin === 'normal'
? !!g.auction && !g.richeseAuction
: g.currentAuctionSale.origin === 'cache' && !!g.richeseAuction &&
@@ -7072,8 +7090,8 @@ function kullSemutaParentSupported(g: Game, frame?: NonNullable<Game['pendingKul
!g.pendingHomeworldShipment && !g.pendingTerrorEntry &&
!g.pendingEcazPlacement && !g.pendingMoritaniPlacement &&
!g.pendingArrivalOverlap && !g.pendingArrivalReaction?.length && !g.pendingExchange &&
!g.pendingRichesePurchaseIncome && !g.pendingChoamMarketGhola && (!g.currentAuctionSale || saleSupported) &&
!g.choamMarket && (!g.richeseAuction || saleSupported) && !g.pendingFremenMove && !g.pendingIxMove &&
!g.pendingRichesePurchaseIncome && (!g.pendingChoamMarketGhola || marketSupported) && (!g.currentAuctionSale || saleSupported) &&
(!g.choamMarket || marketSupported) && (!g.richeseAuction || saleSupported) && !g.pendingFremenMove && !g.pendingIxMove &&
!g.pendingMobileMove && !g.pendingChoamMove && !g.ornithopter && !g.ecologicalStorm &&
!g.summonedWorm && !g.wormRides.length;
}
@@ -9618,6 +9636,10 @@ function semutaOfferSupported(
const marketGholaDiscard = continuation.kind === 'ordinaryCardDiscard' && continuation.effect === 'ghola' &&
g.semutaPreview === true && bareBasicSemutaProfile(g) && ordinaryDiscardIncomeMatches(g, continuation) &&
semutaMarketGholaSupported(g, continuation, entries);
const marketGholaIncomeDiscard = continuation.kind === 'karamaIncomeDiscard' &&
g.semutaPreview === true && bareBasicSemutaProfile(g) && !g.nexusKullPreview &&
(!g.pendingTreacheryDiscard || !!g.pendingTreacheryDiscard.reaction) &&
semutaMarketGholaIncome(g, continuation.use.response);
const richeseDiscard = richeseGiftDiscard || continuation.kind === 'karamaRicheseAuctionDiscard';
const auctionAdvantageDiscard = continuation.kind === 'karamaAuctionAdvantageDiscard';
const terminalDiscard = continuation.kind === 'karamaTerminalDiscard';
@@ -10050,13 +10072,13 @@ function semutaOfferSupported(
(richeseGiftDiscard || !g.pendingRicheseGift) &&
(richeseGiftDiscard || (continuation.kind === 'karamaIncomeDiscard' &&
continuation.use.response.kind === 'richesePurchaseIncome') || !g.pendingRichesePurchaseIncome) &&
(richeseGiftDiscard || saleCostDiscard || marketGholaDiscard || (continuation.kind === 'choamSaleDiscard'
(richeseGiftDiscard || saleCostDiscard || marketGholaDiscard || marketGholaIncomeDiscard || (continuation.kind === 'choamSaleDiscard'
? !!g.choamMarket && !g.choamMarket.sale &&
!g.choamMarket.trade && !g.biddingEnd && !g.currentAuctionSale
: continuation.kind === 'kaitainDiscard'
? g.biddingEnd?.event === continuation.event && biddingEndError(g) === null
: !g.choamMarket)) &&
(richeseGiftDiscard || marketGholaDiscard || !g.pendingChoamMarketGhola);
(richeseGiftDiscard || marketGholaDiscard || marketGholaIncomeDiscard || !g.pendingChoamMarketGhola);
}
function semutaContext(g: Game, owner: Player): SemutaContext {
const pending = g.pendingTreacheryDiscard!;
+10 -6
View File
@@ -6,6 +6,15 @@ import { quoteChoamSaleCancellation } from './choam-sale-cancellation';
type OrdinaryDiscard = Extract<NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
{ kind: 'ordinaryCardDiscard' }>;
/** Shared bounds for the original discard and its later income response. */
export function semutaMarketGholaProfile(game: Game): boolean {
return game.status === 'playing' && !game.advanced && game.semutaPreview === true &&
game.expansions.includes('choam') && !game.homeworlds && !game.nexusCards &&
!game.leaderSkills && !game.techTokens && !game.strongholdCards && !game.ecazTreachery &&
!game.nexusKullPreview && !game.discoveryEnabled && !game.discoveries && !game.discoveryStash &&
!game.greatMaker && !game.sandtrout && !game.coalitionState;
}
/** Qualify the original Ghola discard while its CHOAM sale is suspended.
* The caller separately owns the Basic Semuta profile and the ordinary
* revival-income qualification. This never changes a saved frame. */
@@ -13,12 +22,7 @@ export function semutaMarketGholaSupported(
game: Game, continuation: OrdinaryDiscard, entries: FreshDiscardBatch['entries'],
): boolean {
const pending = game.pendingChoamMarketGhola, discard = game.pendingTreacheryDiscard;
if (game.status !== 'playing' || game.advanced || game.semutaPreview !== true ||
!game.expansions.includes('choam') || game.homeworlds || game.nexusCards ||
game.leaderSkills || game.techTokens || game.strongholdCards || game.ecazTreachery ||
game.kullPreview || game.nexusKullPreview ||
game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker ||
game.sandtrout || game.coalitionState || !pending || !game.choamMarket ||
if (!semutaMarketGholaProfile(game) || !pending || !game.choamMarket ||
continuation.kind !== 'ordinaryCardDiscard' || continuation.effect !== 'ghola' ||
pending.stage !== 'discard' || pending.turn !== game.turn || pending.phase !== game.phase ||
pending.player !== continuation.player || pending.card !== continuation.card ||
+15 -4
View File
@@ -11,15 +11,17 @@ export const semutaMarketGholaInventory = inventory;
/** Public Basic setup and real CHOAM market declaration. The original
* starting card order and Spice positions are selected before they are drawn.
* After setup, the real Ghola and Semuta cards are moved from their respective
* decks to their owners' hands and one CHOAM reserve is moved to Tanks. Those
* decks to their owners' hands and one CHOAM reserve is moved to Tanks. When
* selected, physical Kull and a second Karama are also moved from the deck to
* CHOAM and Tleilaxu respectively. Those
* conserved staging choices establish the relevant holdings and casualty; no
* response, market, discard receipt, wallet, revival or income is staged. */
export function createSemutaMarketGholaFixture(options: {fullHand?: boolean; withoutTleilaxu?: boolean} = {}) {
export function createSemutaMarketGholaFixture(options: {fullHand?: boolean; withoutTleilaxu?: boolean; withKull?: boolean} = {}) {
const owner = 'choam', claimant = 'richese',
incomeOwner = options.withoutTleilaxu ? 'atreides' : 'tleilaxu', counterOwner = 'emperor';
let game = createGame('SEMUTAMARKETGHOLA', newPlayer(claimant, 'Richese', 'richese'), false, ['choam', 'ix']);
for (const id of [owner, incomeOwner, counterOwner] as const) joinGame(game, newPlayer(id, id, id));
game = act(game, claimant, {type:'moduleOption', module:'kullPreview', enabled:false});
if (!options.withKull) game = act(game, claimant, {type:'moduleOption', module:'kullPreview', enabled:false});
for (const seat of game.players) game = act(game, seat.id, {type:'ready'});
game = act(game, claimant, {type:'start'});
assert.equal(game.status, 'setup');
@@ -45,6 +47,15 @@ export function createSemutaMarketGholaFixture(options: {fullHand?: boolean; wit
player(game, claimant).hand.push(game.richeseCache!.splice(semutaIndex, 1)[0]);
const gholaIndex = game.deck.findIndex(card => card.effect === 'ghola'); assert.ok(gholaIndex >= 0);
const ghola = game.deck.splice(gholaIndex, 1)[0]; player(game, owner).hand.push(ghola);
let kull: string | undefined, kullCounter: string | undefined;
if (options.withKull) {
assert.equal(game.kullPreview, true);
const index = game.deck.findIndex(card => card.id === 'ix-kull-wahad'); assert.ok(index >= 0);
const card = game.deck.splice(index, 1)[0]; player(game, owner).hand.push(card); kull = card.id;
const counterIndex = game.deck.findIndex(candidate => candidate.effect === 'karama'); assert.ok(counterIndex >= 0);
const secondCounter = game.deck.splice(counterIndex, 1)[0]; player(game, incomeOwner).hand.push(secondCounter);
kullCounter = secondCounter.id;
}
const choam = player(game, owner); assert.ok(choam.reserves > 0);
choam.reserves--; choam.tanks++;
if (options.fullHand) while (player(game, claimant).hand.length < 4) {
@@ -63,7 +74,7 @@ export function createSemutaMarketGholaFixture(options: {fullHand?: boolean; wit
const event = game.pendingTreacheryDiscard!.batch.event;
assert.deepEqual(inventory(game), originalInventory);
return {game, source, owner, claimant, incomeOwner, counterOwner, ghola:ghola.id,
semuta:SEMUTA_DRUG_ID, counter:counter.id, sale:sale.id, event, originalInventory,
semuta:SEMUTA_DRUG_ID, counter:counter.id, sale:sale.id, event, originalInventory, kull, kullCounter,
market, response, gholaAction, fullHand:!!options.fullHand};
}
+121
View File
@@ -0,0 +1,121 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { createSemutaMarketGholaFixture as fixture, actSemutaMarketGhola as act,
semutaMarketGholaPlayer as player, semutaMarketGholaInventory as inventory,
passSemutaMarketGhola as pass, claimSemutaMarketGhola as claim,
allowSemutaMarketGholaIncome as allowIncome } from './fixture-semuta-market-ghola';
/** Resolve the existing public responses, but never choose a new market action. */
function finishOriginalSale(source: Game): Game {
let game = source;
for (let count = 0; count < 30; count++) {
if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') { game = pass(game); continue; }
if (game.response) {
const seat = game.players.find(candidate => !game.response!.passed.includes(candidate.id));
assert.ok(seat, 'An unresolved response needs an unpassed seat');
game = act(game, seat.id, {type:'passResponse'}); continue;
}
assert.equal(game.pendingKull ?? null, null);
assert.equal(game.pendingChoamMarketGhola ?? null, null);
return game;
}
throw Error('The original income and market must finish');
}
for (const claims of [false, true]) for (const kullOutcome of ['decline', 'success', 'counter'] as const)
void test(`default Kull: Semuta ${claims ? 'claims' : 'passes'} market Ghola; Kull ${kullOutcome} preserves income and sale`, () => {
const f = fixture({withKull:true}); assert.ok(f.kull); assert.ok(f.kullCounter);
const beforeIncome = player(f.source, f.incomeOwner).spice, beforeSale = player(f.source, f.owner).spice;
assert.equal(f.game.kullPreview, true);
let game = claims ? claim(f.game, f.claimant, f.ghola) : pass(f.game);
assert.equal(game.response?.kind, 'revivalIncome');
assert.deepEqual(game.pendingChoamMarketGhola?.response, f.response);
assert.ok(viewGame(game, f.counterOwner).responseControls?.cancelCards.includes(f.counter));
game = act(game, f.counterOwner, {type:'card', card:f.counter, mode:'cancel'});
const reaction = viewGame(game, f.owner).kullReaction; assert.ok(reaction);
assert.ok(reaction.plays.some(play => play.source === 'printed' && play.card.id === f.kull));
game = kullOutcome === 'decline'
? act(game, f.owner, {type:'kullDecision', event:reaction.event, decline:true})
: act(game, f.owner, {type:'kullDecision', event:reaction.event, source:'printed', card:f.kull});
if (kullOutcome === 'counter') {
assert.ok(viewGame(game, f.incomeOwner).responseControls?.cancelCards.includes(f.kullCounter));
game = act(game, f.incomeOwner, {type:'card', card:f.kullCounter, mode:'cancel'});
}
if (kullOutcome === 'success') {
for (let count = 0; !game.pendingTreacheryDiscard && game.response && count < game.players.length; count++) {
const seat = game.players.find(candidate => !game.response!.passed.includes(candidate.id));
assert.ok(seat); game = act(game, seat.id, {type:'passResponse'});
}
}
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(game.pendingTreacheryDiscard?.continuation.kind,
kullOutcome === 'decline' ? 'karamaIncomeDiscard' :
kullOutcome === 'success' ? 'kullSuccessDiscard' : 'kullCounterDiscard');
game = pass(game);
if (kullOutcome === 'counter') {
assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'karamaIncomeDiscard');
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
}
game = finishOriginalSale(game);
assert.equal(player(game, f.incomeOwner).spice, beforeIncome + Number(kullOutcome === 'success'));
assert.equal(player(game, f.owner).spice, beforeSale + 2);
assert.equal(player(game, f.owner).tanks, player(f.source, f.owner).tanks - 1);
assert.equal(player(game, f.claimant).hand.some(card => card.id === f.ghola), claims);
assert.equal(game.discard.filter(card => card.id === f.sale).length, 1);
assert.equal(player(game, f.counterOwner).hand.some(card => card.id === f.counter), kullOutcome === 'success');
assert.equal(game.discard.filter(card => card.id === f.kull).length, Number(kullOutcome === 'success'));
assert.deepEqual(inventory(game), f.originalInventory);
});
for (const kullOutcome of ['decline', 'success', 'counter'] as const)
void test(`resumed sale cancellation: Kull ${kullOutcome} offers its physical cost to Semuta`, () => {
const f = fixture({withKull:true}); assert.ok(f.kull); assert.ok(f.kullCounter);
const beforeIncome = player(f.source, f.incomeOwner).spice, beforeSale = player(f.source, f.owner).spice;
let game = allowIncome(pass(f.game));
assert.equal(game.response?.kind, 'choamSale');
assert.deepEqual(game.response, f.response);
assert.ok(viewGame(game, f.counterOwner).responseControls?.cancelCards.includes(f.counter));
game = act(game, f.counterOwner, {type:'card', card:f.counter, mode:'cancel'});
const reaction = viewGame(game, f.owner).kullReaction; assert.ok(reaction);
game = kullOutcome === 'decline'
? act(game, f.owner, {type:'kullDecision', event:reaction.event, decline:true})
: act(game, f.owner, {type:'kullDecision', event:reaction.event, source:'printed', card:f.kull});
if (kullOutcome === 'counter') {
assert.ok(viewGame(game, f.incomeOwner).responseControls?.cancelCards.includes(f.kullCounter));
game = act(game, f.incomeOwner, {type:'card', card:f.kullCounter, mode:'cancel'});
}
const cost = kullOutcome === 'decline' ? f.counter : kullOutcome === 'success' ? f.kull : f.kullCounter;
for (let count = 0; !game.pendingTreacheryDiscard && game.response && count < game.players.length; count++) {
const seat = game.players.find(candidate => !game.response!.passed.includes(candidate.id)); assert.ok(seat);
game = act(game, seat.id, {type:'passResponse'});
}
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.ok(game.pendingTreacheryDiscard?.batch.entries.some(entry => entry.card.id === cost));
game = claim(game, f.claimant, cost);
game = finishOriginalSale(game);
assert.equal(player(game, f.incomeOwner).spice, beforeIncome + 1);
assert.equal(player(game, f.owner).spice, beforeSale + (kullOutcome === 'success' ? 2 : 0));
assert.equal(player(game, f.owner).hand.some(card => card.id === f.sale), kullOutcome !== 'success');
assert.equal(game.discard.filter(card => card.id === f.sale).length, Number(kullOutcome === 'success'));
assert.equal(player(game, f.claimant).hand.filter(card => card.id === cost).length, 1);
assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
assert.equal(player(game, f.owner).tanks, player(f.source, f.owner).tanks - 1);
assert.deepEqual(inventory(game), f.originalInventory);
});
void test('all minimal bot profiles can answer Kull against the resumed sale', () => {
const f = fixture({withKull:true});
let game = allowIncome(pass(f.game));
assert.equal(game.response?.kind, 'choamSale');
game = act(game, f.counterOwner, {type:'card', card:f.counter, mode:'cancel'});
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, f.owner); view.players.find(seat => seat.id === f.owner)!.bot = difficulty;
const action = botActions(view).find(candidate => candidate.type === 'kullDecision'); assert.ok(action, difficulty);
const continued = act(game, f.owner, action);
assert.notEqual(continued.pendingKull?.stage, 'offer');
assert.deepEqual(inventory(continued), f.originalInventory);
}
});
+16 -2
View File
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { createGame, newPlayer, type Game } from '../game/engine';
import { choamGholaEvent } from '../game/choam-market-ghola';
import { semutaMarketGholaSupported as supported } from '../game/semuta-market-ghola';
import { semutaMarketGholaProfile as profile, semutaMarketGholaSupported as supported } from '../game/semuta-market-ghola';
import type { FreshDiscardBatch } from '../game/semuta-drug';
type Ordinary = Extract<NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
@@ -44,6 +44,8 @@ void test('a fresh actual market Ghola with and without native income is admitte
const {game,continuation,entries} = source(withIncome), before = structuredClone(game);
assert.equal(supported(game,continuation,entries),true);
assert.deepEqual(game,before);
game.kullPreview = true;
assert.equal(supported(game,continuation,entries),true);
}
});
@@ -60,7 +62,8 @@ void test('market, sequence, source, income and nested-control changes are rejec
const changes: Array<(s:ReturnType<typeof source>) => void> = [
s => { s.game.advanced = true; },
s => { s.game.techTokens = {} as NonNullable<Game['techTokens']>; },
s => { s.game.kullPreview = true; },
s => { s.game.sandtrout = true; },
s => { s.game.coalitionState = {} as NonNullable<Game['coalitionState']>; },
s => { s.game.nexusKullPreview = true; },
s => { s.game.semutaPreview = undefined; },
s => { s.game.pendingChoamMarketGhola!.turn++; },
@@ -89,3 +92,14 @@ void test('market, sequence, source, income and nested-control changes are rejec
assert.deepEqual(candidate.game,before);
}
});
void test('market Ghola income shares the same optional-module profile boundary', () => {
const {game} = source();
assert.equal(profile(game), true);
game.techTokens = {} as NonNullable<Game['techTokens']>;
assert.equal(profile(game), false);
game.techTokens = null; game.sandtrout = true;
assert.equal(profile(game), false);
game.sandtrout = false; game.coalitionState = {} as NonNullable<Game['coalitionState']>;
assert.equal(profile(game), false);
});