157 lines
8.9 KiB
TypeScript
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);
|
|
}
|
|
});
|