Files
Dune/tests/semuta-homeworld-choam-movement.test.ts

160 lines
10 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, handLimit, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { splitLocation } from '../game/board';
import { advanceSemutaHomeworldChoam, reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam';
import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
import {
createSemutaHomeworldChoamMovementFixture, disposeSemutaHomeworldChoamMovement,
finishSemutaHomeworldChoamMovement,
semutaHomeworldChoamMovementResources as resources, semutaHomeworldChoamMovementStock as stock,
withoutSemutaHomeworldChoamMovementHolder,
type SemutaHomeworldChoamMovementEffect,
} from './fixture-semuta-homeworld-choam-movement';
const effects: SemutaHomeworldChoamMovementEffect[] = ['kulon', 'baliset'];
for (const advanced of [false, true]) for (const effect of effects) {
const label = `${advanced ? 'Advanced' : 'Basic'} Homeworld ${effect}`;
for (const claim of [false, true]) void test(`${label}: Semuta ${claim ? 'claim' : 'pass'} preserves paid native shipment and applies the printed movement power once`, () => {
const f = createSemutaHomeworldChoamMovementFixture(effect, advanced);
const inventory = stock(f.game), paid = resources(f.game);
assert.equal(player(f.afterShipment, f.owner).spice, player(f.beforeShipment, f.owner).spice - 2,
'One ordinary off-planet force into desert pays exactly two spice');
assert.equal(player(f.afterShipment, f.owner).reserves, player(f.beforeShipment, f.owner).reserves - 1);
assert.equal(player(f.afterShipment, f.owner).forces[effect === 'kulon' ? f.origin : f.destination], 1);
assert.deepEqual(f.afterShipment.homeworlds?.custody, f.setup.homeworlds?.custody);
if (effect === 'kulon') assert.throws(() => applyAction(f.game, f.owner, f.move));
else {
assert.equal(f.game.decision?.kind, 'choamMovement');
assert.deepEqual(player(f.game, f.target).forces, player(f.beforeMove, f.target).forces);
assert.equal(player(f.game, f.target).moved, 0, 'Declared move remains wholly uncommitted');
}
const offered = disposeSemutaHomeworldChoamMovement(f);
assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true);
assert.equal(offered.discard.filter(card => card.id === f.card).length, 1);
assert.equal(player(offered, f.owner).hand.some(card => card.id === f.card), false);
assert.deepEqual(resources(offered), paid, 'Paid prefix, original force custody and wallets are frozen during the offer');
assert.deepEqual(stock(offered), inventory);
assert.deepEqual(normalizeAutomaticGame(structuredClone(offered)), offered);
assert.throws(() => applyAction(offered, f.target, f.move));
assert.equal(viewGame(offered, f.owner).choamMovementBonus, 0);
assert.deepEqual(viewGame(offered, f.target).balisetRestrictions, []);
const event = offered.pendingTreacheryDiscard!.batch.event;
const resumed = reactSemutaHomeworldChoam(structuredClone(offered), f.holder, claim);
assert.equal(resumed.pendingTreacheryDiscard, null);
assert.deepEqual(resources(resumed), paid);
assert.deepEqual(stock(resumed), inventory);
assert.equal(player(resumed, f.holder).hand.filter(card => card.id === f.card).length, claim ? 1 : 0);
assert.equal(resumed.discard.filter(card => card.id === f.card).length, claim ? 0 : 1);
assert.equal(resumed.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, claim ? 1 : 0);
assert.throws(() => applyAction(resumed, f.holder, { type: 'semutaCommit', event }));
if (effect === 'kulon') {
assert.equal(viewGame(resumed, f.owner).choamMovementBonus, 1);
assert.throws(() => applyAction(resumed, f.owner, f.tooFar!),
'Exactly one extra range, not two');
const moved = applyAction(resumed, f.owner, f.move);
assert.equal(player(moved, f.owner).moved, 1);
assert.equal(player(moved, f.owner).forces[f.origin] ?? 0, 0);
assert.equal(player(moved, f.owner).forces[f.destination], 1);
assert.throws(() => applyAction(moved, f.owner, f.move),
'Kulon does not grant a second move');
} else {
const restrictions = viewGame(resumed, f.target).balisetRestrictions;
assert.equal(restrictions.length, 1);
assert.equal(restrictions[0].territory, splitLocation(f.destination).territory);
assert.equal(resumed.pendingChoamMove, null);
assert.equal(player(resumed, f.target).moved, 0);
assert.deepEqual(player(resumed, f.target).forces, player(f.beforeMove, f.target).forces);
assert.throws(() => applyAction(resumed, f.target, f.move));
assert.throws(() => applyAction(resumed, f.owner, f.action),
'The used printed physical card cannot apply a second restriction');
}
const completed = finishSemutaHomeworldChoamMovement(resumed, f);
assert.equal(completed.phase, 7, 'Real endMovement and CHOAM phase controls reach Spice Collection');
assert.equal(completed.turn, 1);
assert.equal(player(completed, f.target).moved, 1);
assert.equal(player(completed, f.owner).reserves, player(f.afterShipment, f.owner).reserves,
'A response and its suffix never ship the original reserve twice');
assert.deepEqual(completed.homeworlds?.custody, f.setup.homeworlds?.custody);
assert.deepEqual(stock(completed), inventory);
});
void test(`${label}: singleton exchange is legal at the original full hand limit and entitlement remains private`, () => {
const f = createSemutaHomeworldChoamMovementFixture(effect, advanced, true);
const absent = withoutSemutaHomeworldChoamMovementHolder(f.game, f.holder);
assert.equal(player(f.game, f.holder).hand.length, handLimit(player(f.game, f.holder)));
const offered = disposeSemutaHomeworldChoamMovement(f);
const neutral = disposeSemutaHomeworldChoamMovement(f, absent);
assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true,
'Exchanging physical Semuta for the singleton needs no fifth slot');
assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false);
for (const seat of offered.players.filter(seat => seat.id !== f.holder)) {
assert.deepEqual(viewGame(offered, seat.id), viewGame(neutral, seat.id),
'An uninvolved player cannot infer whether Richese owns Semuta');
assert.equal(viewGame(offered, seat.id).semutaReaction?.canCommit, false);
assert.throws(() => applyAction(offered, seat.id,
{ type: 'semutaCommit', event: offered.pendingTreacheryDiscard!.batch.event }));
}
const claimed = reactSemutaHomeworldChoam(offered, f.holder, true);
assert.equal(player(claimed, f.holder).hand.length, handLimit(player(claimed, f.holder)));
assert.equal(player(claimed, f.holder).hand.some(card => card.id === f.card), true);
assert.equal(player(claimed, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), false);
assert.deepEqual(stock(claimed), stock(f.game));
assert.equal(player(finishSemutaHomeworldChoamMovement(claimed, f), f.target).moved, 1);
});
void test(`${label}: all four minimal bot profiles legally claim or pass then finish the native move`, () => {
const f = createSemutaHomeworldChoamMovementFixture(effect, advanced);
const offered = disposeSemutaHomeworldChoamMovement(f);
for (const difficulty of DIFFICULTIES) {
let game: Game = structuredClone(offered);
const own = viewGame(game, f.holder);
own.players.find(seat => seat.id === f.holder)!.bot = difficulty;
const action = botActions(own)[0];
assert.ok(action, `${difficulty} has an entitled neutral receipt action`);
assert.ok(['semutaCommit', 'semutaPass'].includes(action.type));
game = applyAction(game, f.holder, action);
while (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') {
const pending = game.pendingTreacheryDiscard, reaction = pending.reaction;
if (reaction?.stage !== 'offer') throw Error('Missing neutral discard offer');
const actor = game.players.find(seat => !reaction.passed.includes(seat.id))!;
game = applyAction(game, actor.id, { type: 'semutaPass', event: pending.batch.event });
}
assert.equal(game.pendingTreacheryDiscard, null);
assert.equal(player(game, f.holder).hand.some(card => card.id === f.card), action.type === 'semutaCommit');
assert.deepEqual(resources(game), resources(f.game));
const completed = finishSemutaHomeworldChoamMovement(game, f);
assert.equal(player(completed, f.target).moved, 1);
assert.deepEqual(stock(completed), stock(f.game));
}
});
}
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} native Baliset decline and Karama allow the original declared move exactly once`, () => {
const f = createSemutaHomeworldChoamMovementFixture('baliset', advanced);
const declined = applyAction(f.game, f.owner, { type: 'decision', decline: true });
const declared = applyAction(f.game, f.owner, f.action);
const karama = player(declared, f.canceller).hand.find(card => card.name === 'Karama');
assert.ok(karama);
const cancellation = applyAction(declared, f.canceller, { type: 'card', mode: 'cancel', card: karama.id });
const allowed = advanceSemutaHomeworldChoam(cancellation,
game => !game.pendingTreacheryDiscard && !game.response && !game.decision && !game.phaseOpening);
for (const game of [declined, allowed]) {
assert.equal(player(game, f.target).moved, 1);
assert.equal(player(game, f.target).forces[f.origin], player(f.beforeMove, f.target).forces[f.origin] - 1);
assert.equal(player(game, f.target).forces[f.destination], 1);
assert.equal(player(game, f.owner).hand.some(card => card.id === f.card), true);
assert.equal(game.discard.some(card => card.id === f.card), false);
assert.equal(game.pendingChoamMove, null);
assert.deepEqual(viewGame(game, f.target).balisetRestrictions, []);
assert.equal(player(game, f.owner).reserves, player(f.afterShipment, f.owner).reserves);
assert.deepEqual(game.homeworlds?.custody, f.setup.homeworlds?.custody);
assert.deepEqual(stock(game), stock(f.game));
assert.throws(() => applyAction(game, f.owner, { type: 'decision', decline: true }));
assert.throws(() => applyAction(game, f.target, f.move));
}
});