106 lines
5.7 KiB
TypeScript
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);
|
|
});
|