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
@@ -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