Files
Dune/tests/semuta-market-ghola-quote.test.ts
T
Quintine bcca08cac5
Verify and publish checkpoint container / container (push) Successful in 11m25s
Support printed Kull during market Ghola
2026-10-29 08:37:50 +11:00

106 lines
5.7 KiB
TypeScript

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 { 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'],
{kind:'ordinaryCardDiscard'}>;
/** A staged frame isolates admission. The separate runtime suite exercises
* the actual Ghola, market, income and physical continuation. */
function source(withIncome = true) {
const game = createGame('MARKETGHOLA', newPlayer('r','Richese','richese'), false, ['choam','ix']);
game.players.push(newPlayer('c','CHOAM','choam'), newPlayer('a','Atreides','atreides'));
if (withIncome) game.players.push(newPlayer('t','Tleilaxu','tleilaxu'));
game.status = 'playing'; game.turn = 2; game.phase = 2; game.semutaPreview = true;
game.response = null; game.decision = null; game.pendingKarama = null; game.phaseOpening = null;
game.choamMarket = {owner:'c',resume:'phase',blocked:[],sale:{card:'sale',price:2}};
game.treacheryDiscardSequence = 7;
const card = {id:'physical-ghola',name:'Ghola',kind:'special',effect:'ghola'} as const;
game.discard = [{...card}];
game.pendingChoamMarketGhola = {turn:game.turn,phase:game.phase,player:'a',card:card.id,
discardSequence:7,event:'',stage:'discard',response:{kind:'choamSale',owner:'c',passed:[]},
market:structuredClone(game.choamMarket)};
game.pendingChoamMarketGhola.event = choamGholaEvent(game.pendingChoamMarketGhola);
const continuation:Ordinary = {kind:'ordinaryCardDiscard',player:'a',card:card.id,effect:'ghola',
resume:{response:withIncome ? {kind:'revivalIncome',owner:'t',recipient:'a',amount:1,passed:[],
intent:game.pendingChoamMarketGhola.event} : null,decision:null,pendingKarama:null,phaseOpening:null},
stateSignature:'staged'};
const entries:FreshDiscardBatch['entries'] = [{card:{...card},discardedBy:'a',publicFace:true}];
return {game,continuation,entries};
}
function install(frame: ReturnType<typeof source>, reaction = true) {
const {game,continuation,entries} = frame;
game.pendingTreacheryDiscard = {sequence:7,batch:{event:'discard:2:2:7',turn:2,phase:2,
cause:'ordinary:ghola',entries:structuredClone(entries)},continuation:structuredClone(continuation),
...(reaction ? {reaction:{stage:'offer' as const,passed:[]}} : {})};
return frame;
}
void test('a fresh actual market Ghola with and without native income is admitted without mutation', () => {
for (const withIncome of [true,false]) {
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);
}
});
void test('an installed reaction retains its same typed source, while an old unreacted discard stays closed', () => {
const fresh = install(source());
const before = structuredClone(fresh.game);
assert.equal(supported(fresh.game,fresh.continuation,fresh.entries),true);
assert.deepEqual(fresh.game,before);
const old = install(source(),false);
assert.equal(supported(old.game,old.continuation,old.entries),false);
});
void test('market, sequence, source, income and nested-control changes are rejected', () => {
const changes: Array<(s:ReturnType<typeof source>) => void> = [
s => { s.game.advanced = true; },
s => { s.game.techTokens = {} as NonNullable<Game['techTokens']>; },
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++; },
s => { s.game.pendingChoamMarketGhola!.stage = 'income'; },
s => { s.game.pendingChoamMarketGhola!.discardSequence++; },
s => { s.game.pendingChoamMarketGhola!.event = 'stale'; },
s => { s.game.pendingChoamMarketGhola!.card = 'other'; },
s => { s.game.pendingChoamMarketGhola!.player = 'r'; },
s => { s.game.pendingChoamMarketGhola!.market.sale!.price = 3; },
s => { s.game.pendingChoamMarketGhola!.response.owner = 'a'; },
s => { s.game.pendingChoamMarketGhola!.response.passed = ['a','a']; },
s => { s.entries[0].publicFace = false; },
s => { s.entries[0].discardedBy = 'r'; },
s => { s.game.discard = []; },
s => { s.continuation.resume.response!.intent = 'wrong'; },
s => { s.continuation.resume.response!.amount = 2; },
s => { s.continuation.resume.response!.passed = ['r']; },
s => { s.continuation.resume.decision = {kind:'auctionPayment',player:'a'}; },
s => { s.game.response = {kind:'choamSale',owner:'c',passed:[]}; },
s => { s.game.pendingKarama = {} as NonNullable<Game['pendingKarama']>; },
];
for (const change of changes) {
const candidate = source(); change(candidate);
const before = structuredClone(candidate.game);
assert.equal(supported(candidate.game,candidate.continuation,candidate.entries),false);
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);
});