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

+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);
});