Support printed Kull during market Ghola
Verify and publish checkpoint container / container (push) Successful in 11m25s
Verify and publish checkpoint container / container (push) Successful in 11m25s
This commit is contained in:
1 parent
79fc12c1c1
commit
bcca08cac5
10 files changed
+232
-25
No files matched your search
@@ -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
@@ -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 /
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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!;
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user