Support Semuta after market Ghola
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
6ecd26c8d1
commit
79fc12c1c1
11 files changed
+557
-5
No files matched your search
@@ -0,0 +1,100 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { actSemutaNativeMarkets as act, advanceSemutaNativeMarkets as advance,
|
||||
semutaNativeMarketsInventory as inventory, semutaNativeMarketsPlayer as player } from './fixture-semuta-native-markets';
|
||||
|
||||
export const actSemutaMarketGhola = act;
|
||||
export const semutaMarketGholaPlayer = player;
|
||||
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
|
||||
* 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} = {}) {
|
||||
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});
|
||||
for (const seat of game.players) game = act(game, seat.id, {type:'ready'});
|
||||
game = act(game, claimant, {type:'start'});
|
||||
assert.equal(game.status, 'setup');
|
||||
assert.equal(game.semutaPreview, true);
|
||||
assert.ok(!game.leaderSkills && !game.nexusCards && !game.homeworlds);
|
||||
const originalInventory = inventory(game);
|
||||
const ordinary = game.deck.filter(card => card.kind === 'projectile').slice(0, 2);
|
||||
const sale = game.deck.find(card => card.kind === 'worthless');
|
||||
const counter = game.deck.find(card => card.effect === 'karama');
|
||||
assert.equal(ordinary.length, 2); assert.ok(sale); assert.ok(counter);
|
||||
for (const [position, card] of [ordinary[0], sale, ordinary[1], counter].entries()) {
|
||||
const index = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(index >= position);
|
||||
[game.deck[position], game.deck[index]] = [game.deck[index], game.deck[position]];
|
||||
}
|
||||
game = advance(game, state => state.status === 'playing');
|
||||
assert.ok(player(game, owner).hand.some(card => card.id === sale.id));
|
||||
assert.ok(player(game, counterOwner).hand.some(card => card.id === counter.id));
|
||||
const front = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery);
|
||||
assert.ok(front >= 0); game.spiceDeck.unshift(game.spiceDeck.splice(front, 1)[0]);
|
||||
|
||||
const semutaIndex = game.richeseCache!.findIndex(card => card.id === SEMUTA_DRUG_ID);
|
||||
assert.ok(semutaIndex >= 0);
|
||||
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);
|
||||
const choam = player(game, owner); assert.ok(choam.reserves > 0);
|
||||
choam.reserves--; choam.tanks++;
|
||||
if (options.fullHand) while (player(game, claimant).hand.length < 4) {
|
||||
const index = game.deck.findIndex(card => ['projectile','poison','shield','snooper','worthless'].includes(card.kind));
|
||||
assert.ok(index >= 0); player(game, claimant).hand.push(game.deck.splice(index, 1)[0]);
|
||||
}
|
||||
assert.deepEqual(inventory(game), originalInventory);
|
||||
game = advance(game, state => state.decision?.kind === 'choamMarket' && !state.response && !state.phaseOpening);
|
||||
assert.equal(game.decision?.player, owner);
|
||||
game = act(game, owner, {type:'decision', mode:'sell', card:sale.id});
|
||||
assert.equal(game.response?.kind, 'choamSale');
|
||||
const source = structuredClone(game), market = structuredClone(game.choamMarket), response = structuredClone(game.response);
|
||||
const gholaAction:Action = {type:'card', card:ghola.id, amount:1, elite:0};
|
||||
game = act(game, owner, gholaAction);
|
||||
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
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,
|
||||
market, response, gholaAction, fullHand:!!options.fullHand};
|
||||
}
|
||||
|
||||
/** Pass only the current discard offer, without consuming a later response. */
|
||||
export function passSemutaMarketGhola(source:Game):Game {
|
||||
const pending = source.pendingTreacheryDiscard;
|
||||
assert.equal(pending?.reaction?.stage, 'offer');
|
||||
const event = pending!.batch.event;
|
||||
let game = source;
|
||||
for (const seat of source.players) {
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage !== 'offer') break;
|
||||
if (!game.pendingTreacheryDiscard.reaction.passed.includes(seat.id))
|
||||
game = act(game, seat.id, {type:'semutaPass', event});
|
||||
}
|
||||
return game;
|
||||
}
|
||||
|
||||
export function claimSemutaMarketGhola(source:Game, actor:string, card:string):Game {
|
||||
const event = source.pendingTreacheryDiscard!.batch.event;
|
||||
let game = act(source, actor, {type:'semutaCommit', event});
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
||||
game = act(game, actor, {type:'semutaSelect', event, card});
|
||||
return game;
|
||||
}
|
||||
|
||||
export function allowSemutaMarketGholaIncome(source:Game):Game {
|
||||
let game = source;
|
||||
for (let count = 0; game.response?.kind === 'revivalIncome' && 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.notEqual(game.response?.kind, 'revivalIncome');
|
||||
return game;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement, type ReactElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { SemutaReaction } from '../components/semuta-reaction';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import {
|
||||
createSemutaMarketGholaFixture, actSemutaMarketGhola as act,
|
||||
allowSemutaMarketGholaIncome, passSemutaMarketGhola, semutaMarketGholaPlayer as player,
|
||||
} from './fixture-semuta-market-ghola';
|
||||
|
||||
type Props = Parameters<typeof SemutaReaction>[0];
|
||||
type Control = { children?: unknown; disabled?: boolean; onClick: () => void };
|
||||
function controls(node: unknown): Control[] {
|
||||
if (Array.isArray(node)) return node.flatMap(controls);
|
||||
if (!node || typeof node !== 'object' || !('props' in node)) return [];
|
||||
const props = (node as ReactElement<{children?: unknown; disabled?: boolean; onClick?: () => void}>).props;
|
||||
return [...(props.onClick ? [{...props, onClick: props.onClick}] : []), ...controls(props.children)];
|
||||
}
|
||||
const markup = (props: Props) => renderToStaticMarkup(createElement(SemutaReaction, props));
|
||||
function control(game: Game, actor: string, label: 'Continue' | 'Commit Semuta Drug', busy = false) {
|
||||
const sent: Action[] = [], props = {game:viewGame(game, actor), busy, act(action: Action) {sent.push(action);}};
|
||||
const button = controls(SemutaReaction(props)).find(candidate => candidate.children === label);
|
||||
assert.ok(button, `The actual fresh discard offers ${label}`);
|
||||
assert.equal(button.disabled, busy);
|
||||
button.onClick();
|
||||
if (busy) { assert.deepEqual(sent, []); return null; }
|
||||
assert.equal(sent.length, 1);
|
||||
assert.deepEqual(sent[0], {type:label === 'Continue' ? 'semutaPass' : 'semutaCommit',
|
||||
event:props.game.semutaReaction!.event});
|
||||
return sent[0];
|
||||
}
|
||||
|
||||
function parked(game: Game) {
|
||||
const pending = game.pendingTreacheryDiscard, market = game.pendingChoamMarketGhola;
|
||||
assert.ok(pending?.continuation.kind === 'ordinaryCardDiscard');
|
||||
assert.equal(pending.continuation.effect, 'ghola');
|
||||
assert.ok(market);
|
||||
assert.equal(market.response.kind, 'choamSale');
|
||||
assert.deepEqual(game.choamMarket, market.market);
|
||||
const income = pending.continuation.resume.response;
|
||||
assert.ok(income?.kind === 'revivalIncome');
|
||||
assert.equal(game.response, null);
|
||||
return {event:pending.batch.event, response:structuredClone(market.response),
|
||||
market:structuredClone(market.market), income:structuredClone(income)};
|
||||
}
|
||||
|
||||
void test('every seat sees a neutral persistent market-Ghola offer; a pass leaves the parent and income parked', () => {
|
||||
const f = createSemutaMarketGholaFixture(), game = f.game;
|
||||
const original = parked(game), before = structuredClone(game);
|
||||
for (const seat of game.players) {
|
||||
const view = viewGame(game, seat.id), reaction = view.semutaReaction;
|
||||
assert.ok(reaction); assert.equal(reaction.stage, 'offer');
|
||||
assert.deepEqual(reaction.candidates, []);
|
||||
const html = markup({game:view, busy:false, act() {}});
|
||||
assert.match(html, /Fresh Treachery discard/);
|
||||
assert.match(html, /Continue when you are ready/);
|
||||
assert.doesNotMatch(html, /Ghola/);
|
||||
}
|
||||
assert.deepEqual(game, before, 'Rendering does not acknowledge or resume the parent.');
|
||||
assert.equal(control(game, f.owner, 'Continue', true), null);
|
||||
const pass = control(game, f.owner, 'Continue'); assert.ok(pass);
|
||||
assert.throws(() => act(game, f.owner, {...pass,event:'stale-market-ghola'}));
|
||||
assert.deepEqual(game, before, 'A stale event cannot change the pending response.');
|
||||
const next = act(game, f.owner, pass);
|
||||
assert.deepEqual(parked(next), original);
|
||||
assert.match(markup({game:viewGame(next, f.owner), busy:false, act() {}}), /You continued. Waiting for other seats/);
|
||||
const continued = passSemutaMarketGhola(next);
|
||||
assert.equal(continued.pendingTreacheryDiscard, null);
|
||||
assert.deepEqual(continued.response, original.income);
|
||||
assert.deepEqual(continued.pendingChoamMarketGhola?.response, original.response);
|
||||
const restored = allowSemutaMarketGholaIncome(continued);
|
||||
assert.equal(restored.pendingChoamMarketGhola, null);
|
||||
assert.deepEqual(restored.response, original.response);
|
||||
assert.deepEqual(restored.choamMarket, original.market);
|
||||
});
|
||||
|
||||
for (const fullHand of [false, true]) void test(
|
||||
`the market-Ghola claim resumes the same income and sale${fullHand ? ' with a full hand' : ''}`, () => {
|
||||
const f = createSemutaMarketGholaFixture({fullHand}), game = f.game;
|
||||
const original = parked(game), before = structuredClone(game);
|
||||
const view = viewGame(game, f.claimant);
|
||||
assert.equal(view.semutaReaction?.canCommit, true);
|
||||
const html = markup({game:view,busy:false,act() {}});
|
||||
assert.match(html, /Commit Semuta Drug/);
|
||||
assert.doesNotMatch(html, /Ghola/);
|
||||
assert.equal(control(game, f.claimant, 'Commit Semuta Drug', true), null);
|
||||
const commit = control(game, f.claimant, 'Commit Semuta Drug'); assert.ok(commit);
|
||||
assert.throws(() => act(game, f.claimant, {...commit,event:'stale-market-ghola'}));
|
||||
assert.deepEqual(game, before);
|
||||
let next = act(game, f.claimant, commit);
|
||||
if (next.pendingTreacheryDiscard?.reaction?.stage === 'select') {
|
||||
const selection = viewGame(next, f.claimant).semutaReaction;
|
||||
assert.ok(selection && selection.stage === 'select');
|
||||
assert.deepEqual(selection.candidates.map(card => card.id), [f.ghola]);
|
||||
assert.deepEqual(viewGame(next, f.owner).semutaReaction?.candidates, []);
|
||||
next = act(next, f.claimant, {type:'semutaSelect',event:original.event,card:f.ghola});
|
||||
}
|
||||
assert.equal(next.pendingTreacheryDiscard, null);
|
||||
assert.deepEqual(next.response, original.income);
|
||||
assert.deepEqual(next.choamMarket, original.market);
|
||||
assert.deepEqual(next.pendingChoamMarketGhola?.response, original.response);
|
||||
assert.ok(player(next, f.claimant).hand.some(card => card.id === f.ghola));
|
||||
assert.ok(!player(next, f.claimant).hand.some(card => card.id === f.semuta));
|
||||
next = allowSemutaMarketGholaIncome(next);
|
||||
assert.equal(next.pendingChoamMarketGhola, null);
|
||||
assert.deepEqual(next.response, original.response);
|
||||
assert.deepEqual(next.choamMarket, original.market);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,91 @@
|
||||
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 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);
|
||||
}
|
||||
});
|
||||
|
||||
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.kullPreview = true; },
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, 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';
|
||||
|
||||
function reject(game:Game, actor:string, action:Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
function allowSale(source:Game):Game {
|
||||
let game = source;
|
||||
for (let count = 0; game.response?.kind === 'choamSale' && count < game.players.length; count++) {
|
||||
const actor = game.players.find(seat => !game.response!.passed.includes(seat.id)); assert.ok(actor);
|
||||
game = act(game, actor.id, {type:'passResponse'});
|
||||
}
|
||||
assert.notEqual(game.response?.kind, 'choamSale');
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') game = pass(game);
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('market Ghola commits the actual return and parks its income and original sale through the Semuta offer', () => {
|
||||
const f = fixture(), pending = f.game.pendingTreacheryDiscard;
|
||||
assert.equal(f.source.response?.kind, 'choamSale');
|
||||
assert.equal(pending?.reaction?.stage, 'offer');
|
||||
assert.equal(pending?.continuation.kind, 'ordinaryCardDiscard');
|
||||
if (pending?.continuation.kind !== 'ordinaryCardDiscard') assert.fail('Retain the original Ghola discard.');
|
||||
assert.equal(pending.continuation.effect, 'ghola');
|
||||
assert.equal(pending.continuation.player, f.owner);
|
||||
assert.equal(pending.continuation.card, f.ghola);
|
||||
assert.equal(pending.continuation.resume.response?.kind, 'revivalIncome');
|
||||
assert.equal(pending.continuation.resume.response?.owner, f.incomeOwner);
|
||||
assert.equal(pending.continuation.resume.response?.recipient, f.owner);
|
||||
assert.equal(pending.continuation.resume.response?.amount, 1);
|
||||
assert.equal(pending.continuation.resume.response?.intent, f.game.pendingChoamMarketGhola?.event);
|
||||
assert.deepEqual(f.game.pendingChoamMarketGhola?.response, f.response);
|
||||
assert.deepEqual(f.game.pendingChoamMarketGhola?.market, f.market);
|
||||
assert.equal(f.game.pendingChoamMarketGhola?.stage, 'discard');
|
||||
assert.equal(f.game.response, null);
|
||||
assert.equal(player(f.game, f.owner).reserves, player(f.source, f.owner).reserves + 1);
|
||||
assert.equal(player(f.game, f.owner).tanks, player(f.source, f.owner).tanks - 1);
|
||||
assert.equal(player(f.game, f.incomeOwner).spice, player(f.source, f.incomeOwner).spice);
|
||||
assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
for (const seat of f.game.players) {
|
||||
const projected = viewGame(f.game, seat.id).semutaReaction; assert.ok(projected);
|
||||
assert.deepEqual(projected.candidates, []);
|
||||
assert.equal(projected.canCommit, seat.id === f.claimant);
|
||||
}
|
||||
reject(f.game, f.claimant, {type:'semutaCommit', event:'stale-market-ghola'});
|
||||
});
|
||||
|
||||
for (const claims of [false, true]) for (const cancelIncome of [false, true]) void test(
|
||||
`market Ghola ${claims ? 'claim' : 'pass'} preserves ${cancelIncome ? 'canceled' : 'paid'} income and original sale`, () => {
|
||||
const f = fixture(), beforeIncome = player(f.source, f.incomeOwner).spice,
|
||||
beforeSale = player(f.source, f.owner).spice;
|
||||
let game = claims ? claim(f.game, f.claimant, f.ghola) : pass(f.game);
|
||||
assert.equal(game.pendingTreacheryDiscard ?? null, null);
|
||||
assert.equal(game.response?.kind, 'revivalIncome');
|
||||
assert.equal(game.response?.intent, game.pendingChoamMarketGhola?.event);
|
||||
assert.deepEqual(game.pendingChoamMarketGhola?.response, f.response);
|
||||
assert.deepEqual(game.choamMarket, f.market);
|
||||
assert.equal(player(game, f.owner).tanks, player(f.source, f.owner).tanks - 1);
|
||||
assert.equal(player(game, f.incomeOwner).spice, beforeIncome);
|
||||
assert.equal(player(game, f.claimant).hand.some(card => card.id === f.ghola), claims);
|
||||
assert.equal(game.discard.some(card => card.id === f.ghola), !claims);
|
||||
if (cancelIncome) {
|
||||
game = act(game, f.counterOwner, {type:'card', card:f.counter, mode:'cancel'});
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') game = pass(game);
|
||||
} else game = allowIncome(game);
|
||||
assert.equal(player(game, f.incomeOwner).spice, beforeIncome + Number(!cancelIncome));
|
||||
assert.equal(game.pendingChoamMarketGhola ?? null, null);
|
||||
if (game.response?.kind === 'choamSale') {
|
||||
assert.deepEqual(game.response, f.response);
|
||||
assert.deepEqual(game.choamMarket, f.market);
|
||||
assert.equal(player(game, f.owner).spice, beforeSale);
|
||||
} else {
|
||||
// When the counter consumed the last usable Karama, the restored sale
|
||||
// may immediately advance through the existing automatic response path.
|
||||
assert.equal(game.response ?? null, null);
|
||||
assert.equal(player(game, f.owner).spice, beforeSale + 2);
|
||||
assert.equal(player(game, f.owner).hand.some(card => card.id === f.sale), false);
|
||||
}
|
||||
game = allowSale(game);
|
||||
assert.equal(player(game, f.owner).spice, beforeSale + 2);
|
||||
assert.equal(player(game, f.owner).hand.some(card => card.id === f.sale), false);
|
||||
assert.equal(game.discard.filter(card => card.id === f.sale).length, 1);
|
||||
assert.equal(player(game, f.owner).tanks, player(f.source, f.owner).tanks - 1);
|
||||
assert.equal(player(game, f.incomeOwner).spice, beforeIncome + Number(!cancelIncome));
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
reject(game, f.claimant, {type:'semutaCommit', event:f.event});
|
||||
},
|
||||
);
|
||||
|
||||
void test('without Tleilaxu, Semuta claim or pass restores the original sale without income', () => {
|
||||
for (const claims of [false, true]) {
|
||||
const f = fixture({withoutTleilaxu:true}), beforeSale = player(f.source, f.owner).spice;
|
||||
const pending = f.game.pendingTreacheryDiscard;
|
||||
assert.equal(f.game.players.some(seat => seat.faction === 'tleilaxu'), false);
|
||||
assert.equal(pending?.continuation.kind, 'ordinaryCardDiscard');
|
||||
if (pending?.continuation.kind !== 'ordinaryCardDiscard') assert.fail('Retain the original Ghola discard.');
|
||||
assert.equal(pending.continuation.resume.response ?? null, null);
|
||||
assert.equal(player(f.game, f.owner).tanks, player(f.source, f.owner).tanks - 1);
|
||||
let game = claims ? claim(f.game, f.claimant, f.ghola) : pass(f.game);
|
||||
assert.equal(game.pendingChoamMarketGhola ?? null, null);
|
||||
assert.deepEqual(game.response, f.response);
|
||||
assert.deepEqual(game.choamMarket, f.market);
|
||||
assert.equal(player(game, f.owner).spice, beforeSale);
|
||||
assert.equal(player(game, f.claimant).hand.some(card => card.id === f.ghola), claims);
|
||||
assert.equal(game.discard.some(card => card.id === f.ghola), !claims);
|
||||
game = allowSale(game);
|
||||
assert.equal(player(game, f.owner).spice, beforeSale + 2);
|
||||
assert.equal(player(game, f.owner).hand.some(card => card.id === f.sale), false);
|
||||
assert.equal(game.discard.filter(card => card.id === f.sale).length, 1);
|
||||
assert.equal(player(game, f.owner).tanks, player(f.source, f.owner).tanks - 1);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a full-hand Semuta claimant exchanges its physical card and still restores market income', () => {
|
||||
const f = fixture({fullHand:true});
|
||||
assert.equal(player(f.game, f.claimant).hand.length, 4);
|
||||
const game = claim(f.game, f.claimant, f.ghola);
|
||||
assert.equal(player(game, f.claimant).hand.length, 4);
|
||||
assert.ok(player(game, f.claimant).hand.some(card => card.id === f.ghola));
|
||||
assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
|
||||
assert.equal(game.response?.kind, 'revivalIncome');
|
||||
assert.deepEqual(game.pendingChoamMarketGhola?.response, f.response);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
});
|
||||
|
||||
void test('all minimal bot profiles can continue the preserved neutral offer', () => {
|
||||
const f = fixture();
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game = structuredClone(f.game);
|
||||
const view = viewGame(game, f.claimant);
|
||||
view.players.find(seat => seat.id === f.claimant)!.bot = difficulty;
|
||||
const action = botActions(view).find(candidate => candidate.type === 'semutaPass' || candidate.type === 'semutaCommit');
|
||||
assert.ok(action, `${difficulty} needs a legal Semuta response`);
|
||||
game = applyAction(game, f.claimant, action);
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
|
||||
const choice = botActions(viewGame(game, f.claimant)).find(candidate => candidate.type === 'semutaSelect');
|
||||
assert.ok(choice, `${difficulty} needs a legal discard selection`);
|
||||
game = applyAction(game, f.claimant, choice);
|
||||
}
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') game = pass(game);
|
||||
assert.equal(game.response?.kind, 'revivalIncome');
|
||||
assert.deepEqual(game.pendingChoamMarketGhola?.response, f.response);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user