160 lines
10 KiB
TypeScript
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));
|
|
}
|
|
});
|