From bcca08cac5502ce543146ba0da36a495769f89bb Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 29 Oct 2026 08:37:50 +1100 Subject: [PATCH] Support printed Kull during market Ghola --- docs/CHOAM_KULL_DESIGN.md | 14 +++ docs/CURRENT_STATUS.md | 16 +++- docs/RULE_DECISIONS.md | 5 + docs/SEMUTA_DISCARD_CONTINUATIONS.md | 10 +- game/choam-market-ghola.ts | 4 +- game/engine.ts | 34 +++++-- game/semuta-market-ghola.ts | 16 ++-- tests/fixture-semuta-market-ghola.ts | 19 +++- tests/semuta-market-ghola-kull.test.ts | 121 ++++++++++++++++++++++++ tests/semuta-market-ghola-quote.test.ts | 18 +++- 10 files changed, 232 insertions(+), 25 deletions(-) create mode 100644 tests/semuta-market-ghola-kull.test.ts diff --git a/docs/CHOAM_KULL_DESIGN.md b/docs/CHOAM_KULL_DESIGN.md index 2a878367..94fc019e 100644 --- a/docs/CHOAM_KULL_DESIGN.md +++ b/docs/CHOAM_KULL_DESIGN.md @@ -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 diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 5bf7fd2f..d996403a 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -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 / diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 7919932d..849e1989 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -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 diff --git a/docs/SEMUTA_DISCARD_CONTINUATIONS.md b/docs/SEMUTA_DISCARD_CONTINUATIONS.md index 6dec0975..ba631e05 100644 --- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md +++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md @@ -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 diff --git a/game/choam-market-ghola.ts b/game/choam-market-ghola.ts index 82ddbed0..590f8bb1 100644 --- a/game/choam-market-ghola.ts +++ b/game/choam-market-ghola.ts @@ -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]; diff --git a/game/engine.ts b/game/engine.ts index d5a12ae7..8dd41405 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -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['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 || diff --git a/tests/fixture-semuta-market-ghola.ts b/tests/fixture-semuta-market-ghola.ts index e20f3736..6433dfac 100644 --- a/tests/fixture-semuta-market-ghola.ts +++ b/tests/fixture-semuta-market-ghola.ts @@ -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}; } diff --git a/tests/semuta-market-ghola-kull.test.ts b/tests/semuta-market-ghola-kull.test.ts new file mode 100644 index 00000000..8f540212 --- /dev/null +++ b/tests/semuta-market-ghola-kull.test.ts @@ -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); + } +}); diff --git a/tests/semuta-market-ghola-quote.test.ts b/tests/semuta-market-ghola-quote.test.ts index d61b9000..db259c65 100644 --- a/tests/semuta-market-ghola-quote.test.ts +++ b/tests/semuta-market-ghola-quote.test.ts @@ -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['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) => void> = [ s => { s.game.advanced = true; }, s => { s.game.techTokens = {} as NonNullable; }, - s => { s.game.kullPreview = true; }, + s => { s.game.sandtrout = true; }, + s => { s.game.coalitionState = {} as NonNullable; }, 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; + assert.equal(profile(game), false); + game.techTokens = null; game.sandtrout = true; + assert.equal(profile(game), false); + game.sandtrout = false; game.coalitionState = {} as NonNullable; + assert.equal(profile(game), false); +});