Files
Dune/tests/semuta-guild-income.test.ts

131 lines
8.0 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, handLimit, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import {
createSemutaGuildIncomeFixture, reactSemutaGuildIncome,
semutaGuildIncomeInventory,
semutaGuildIncomePosition, type SemutaGuildIncomeFixture,
} from './fixture-semuta-guild-income';
import { semutaEmperorPlayer as semutaGuildIncomePlayer } from './fixture-semuta-emperor-share';
function openPaid(f: SemutaGuildIncomeFixture): Game {
const paid = f.income;
const payerBefore = semutaGuildIncomePlayer(f.game, f.payer), payerAfter = semutaGuildIncomePlayer(paid, f.payer);
assert.equal(f.cost, 6, 'Three ordinary sand arrivals pay full price, not the Guild half rate');
assert.equal(payerAfter.spice, payerBefore.spice - f.cost);
assert.equal(payerAfter.reserves, payerBefore.reserves - f.amount);
assert.equal(payerAfter.forces[f.key], (payerBefore.forces[f.key] ?? 0) + f.amount);
assert.equal(payerAfter.shipped, true);
assert.equal(payerAfter.moved, payerBefore.moved);
assert.equal(paid.active, f.payer);
assert.deepEqual(paid.movementRemaining, f.game.movementRemaining);
assert.equal(semutaGuildIncomePlayer(paid, f.owner).spice, semutaGuildIncomePlayer(f.game, f.owner).spice);
return paid;
}
function openCost(f: SemutaGuildIncomeFixture, paid: Game): Game {
assert.ok(viewGame(paid, f.canceller).responseControls!.cancelCards.includes(f.karama.id));
assert.deepEqual(viewGame(paid, f.owner).responseControls!.cancelCards, [], 'Guild is income owner, not foreign canceller');
assert.throws(() => applyAction(paid, f.payer, f.cancel),
'The shipper cannot spend the foreign Emperor-controlled cost');
const pending = applyAction(paid, f.canceller, f.cancel);
const discard = pending.pendingTreacheryDiscard;
assert.ok(discard, 'Printed Karama needs its immediate public Semuta opportunity');
assert.equal(discard.batch.cause, 'karama:guildIncome');
assert.deepEqual(discard.batch.entries, [{ card: f.karama, discardedBy: f.canceller, publicFace: true }]);
assert.equal(discard.reaction?.stage, 'offer');
assert.deepEqual(semutaGuildIncomePosition(pending), semutaGuildIncomePosition(paid));
assert.equal(semutaGuildIncomePlayer(pending, f.canceller).hand.some(card => card.id === f.karama.id), false);
for (const seat of pending.players) {
const offer = viewGame(pending, seat.id).semutaReaction!;
assert.equal(offer.canCommit, seat.id === f.holder);
}
assert.throws(() => applyAction(pending, f.payer, { type: 'endMovement' }));
assert.throws(() => applyAction(pending, f.payer, { type: 'semutaCommit', event: discard.batch.event }));
return pending;
}
function assertCompletion(f: SemutaGuildIncomeFixture, paid: Game, pending: Game, done: Game, claim: boolean): void {
const event = pending.pendingTreacheryDiscard!.batch.event;
const holder = semutaGuildIncomePlayer(done, f.holder);
assert.equal(holder.hand.some(card => card.id === f.karama.id), claim);
assert.equal(holder.hand.some(card => card.id === f.semuta), !claim);
assert.equal(done.discard.filter(card => card.id === f.karama.id).length, Number(!claim));
assert.equal(done.discard.filter(card => card.id === f.semuta).length, Number(claim));
assert.equal(done.resolvedTreacheryDiscardSequence, pending.pendingTreacheryDiscard!.sequence);
assert.deepEqual(semutaGuildIncomePosition(done), semutaGuildIncomePosition(paid),
'Canceling Guild income never refunds, reprices, re-ships, moves or advances the native owner');
assert.deepEqual(semutaGuildIncomeInventory(done), semutaGuildIncomeInventory(f.setup));
assert.throws(() => applyAction(done, f.payer, f.ship));
assert.throws(() => applyAction(done, f.canceller, f.cancel));
assert.throws(() => applyAction(done, f.holder, { type: 'semutaCommit', event }));
// Compare the next native Movement scheduling with the original allowed
// income branch. Only Guild's collected spice differs; no owner/suffix is invented.
const allowed = applyAction(paid, f.canceller, { type: 'passResponse' });
assert.equal(semutaGuildIncomePlayer(allowed, f.owner).spice,
semutaGuildIncomePlayer(f.game, f.owner).spice + f.cost);
const ordinarySuffix = applyAction(allowed, f.payer, { type: 'endMovement' });
const canceledSuffix = applyAction(done, f.payer, { type: 'endMovement' });
assert.equal(canceledSuffix.turn, ordinarySuffix.turn);
assert.equal(canceledSuffix.phase, ordinarySuffix.phase);
assert.equal(canceledSuffix.active, ordinarySuffix.active);
assert.deepEqual(canceledSuffix.movementRemaining, ordinarySuffix.movementRemaining);
assert.deepEqual(canceledSuffix.decision, ordinarySuffix.decision);
assert.deepEqual(canceledSuffix.response, ordinarySuffix.response);
assert.ok(!canceledSuffix.movementRemaining?.includes(f.payer));
const payer = semutaGuildIncomePlayer(canceledSuffix, f.payer);
assert.equal(payer.spice, semutaGuildIncomePlayer(f.game, f.payer).spice - f.cost);
assert.equal(payer.reserves, semutaGuildIncomePlayer(f.game, f.payer).reserves - f.amount);
assert.equal(payer.forces[f.key], f.amount);
assert.equal(payer.shipped, true);
assert.equal(semutaGuildIncomePlayer(canceledSuffix, f.owner).spice, semutaGuildIncomePlayer(f.game, f.owner).spice);
assert.deepEqual(semutaGuildIncomeInventory(canceledSuffix), semutaGuildIncomeInventory(f.setup));
for (const seat of canceledSuffix.players) viewGame(canceledSuffix, seat.id);
}
for (const advanced of [false, true]) {
for (const claim of [false, true]) void test(
`${advanced ? 'Advanced' : 'Basic'} native paid reserve shipment Semuta ${claim ? 'claims' : 'passes'} income-canceling Karama without refund or repeat arrival`, () => {
const f = createSemutaGuildIncomeFixture({ advanced }), paid = openPaid(f), pending = openCost(f, paid);
const done = reactSemutaGuildIncome(pending, f, claim);
assertCompletion(f, paid, pending, done, claim);
},
);
void test(`${advanced ? 'Advanced' : 'Basic'} full-hand Richese exchanges Semuta for the exact foreign Guild-income cost`, () => {
const f = createSemutaGuildIncomeFixture({ advanced, fullHand: true });
const held = semutaGuildIncomePlayer(f.game, f.holder);
assert.equal(held.hand.length, handLimit(held));
const paid = openPaid(f), pending = openCost(f, paid);
assert.equal(viewGame(pending, f.holder).semutaReaction!.canCommit, true);
const done = reactSemutaGuildIncome(pending, f, true);
assert.equal(semutaGuildIncomePlayer(done, f.holder).hand.length, handLimit(held));
assert.deepEqual(semutaGuildIncomePlayer(done, f.holder).hand.map(card => card.id).sort(),
[...held.hand.filter(card => card.id !== f.semuta).map(card => card.id), f.karama.id].sort());
assertCompletion(f, paid, pending, done, true);
});
}
void test('native Guild-income denial waits through neutral passes and the original paid Movement stays owned', () => {
const f = createSemutaGuildIncomeFixture(), paid = openPaid(f), pending = openCost(f, paid);
const event = pending.pendingTreacheryDiscard!.batch.event;
let game = pending;
for (const actor of [f.owner, f.payer, f.canceller]) {
game = applyAction(game, actor, { type: 'semutaPass', event });
assert.equal(game.pendingTreacheryDiscard!.reaction!.stage, 'offer');
assert.deepEqual(semutaGuildIncomePosition(game), semutaGuildIncomePosition(paid));
assert.equal(game.discard.filter(card => card.id === f.karama.id).length, 1);
}
game = applyAction(game, f.holder, { type: 'semutaPass', event });
assertCompletion(f, paid, pending, game, false);
});
void test('legal Advanced Semuta bot finishes the full-hand Guild-income cancellation and resumes native Movement', () => {
const f = createSemutaGuildIncomeFixture({ advanced: true, fullHand: true });
const paid = openPaid(f), pending = openCost(f, paid);
const own = viewGame(pending, f.holder);
own.players.find(player => player.id === f.holder)!.bot = 'Easy';
const action = botActions(own)[0];
assert.ok(action);
const done = applyAction(pending, f.holder, action);
assertCompletion(f, paid, pending, done, true);
});