Files
Dune/tests/semuta-market-ghola-runtime.test.ts
T
Quintine 79fc12c1c1
Verify and publish checkpoint container / container (push) Canceled after 0s
Support Semuta after market Ghola
2026-10-29 08:34:18 +11:00

157 lines
8.9 KiB
TypeScript

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