190 lines
12 KiB
TypeScript
190 lines
12 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 { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
|
import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
|
|
import { advanceSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam';
|
|
import {
|
|
semutaHomeworldChoamMovementResources as resources,
|
|
semutaHomeworldChoamMovementStock as stock,
|
|
withoutSemutaHomeworldChoamMovementHolder,
|
|
} from './fixture-semuta-homeworld-choam-movement';
|
|
import { semutaHomeworldChoamReturnsForces } from './fixture-semuta-homeworld-choam-returns';
|
|
import {
|
|
cancelSemutaHomeworldChoamKarama, createSemutaHomeworldChoamKaramaFixture,
|
|
reactSemutaHomeworldChoamKarama, type SemutaHomeworldChoamKaramaFixture,
|
|
} from './fixture-semuta-homeworld-choam-karama';
|
|
|
|
function assertCancellation(f: SemutaHomeworldChoamKaramaFixture, done: Game, claim: boolean) {
|
|
assert.ok(!done.pendingTreacheryDiscard);
|
|
assert.ok(!done.response);
|
|
assert.equal(done.turn, f.beforeDeclaration.turn);
|
|
assert.equal(done.phase, f.beforeDeclaration.phase);
|
|
assert.deepEqual(stock(done), stock(f.beforeDeclaration));
|
|
assert.deepEqual(semutaHomeworldChoamReturnsForces(done), semutaHomeworldChoamReturnsForces(f.beforeDeclaration));
|
|
assert.equal(player(done, f.owner).hand.filter(card => card.id === f.card).length, 1,
|
|
'Canceled printed fuel stays with its original CHOAM owner');
|
|
assert.equal(player(done, f.holder).hand.some(card => card.id === f.card), false);
|
|
assert.equal(done.discard.some(card => card.id === f.card), false);
|
|
assert.equal(player(done, f.canceller).hand.some(card => card.id === f.karama), false);
|
|
assert.equal(player(done, f.holder).hand.some(card => card.id === f.karama), claim);
|
|
assert.equal(player(done, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), !claim);
|
|
assert.equal(done.discard.filter(card => card.id === f.karama).length, Number(!claim));
|
|
assert.equal(done.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, Number(claim));
|
|
for (const seat of done.players) {
|
|
assert.equal(seat.hand.length, player(f.beforeDeclaration, seat.id).hand.length - Number(seat.id === f.canceller),
|
|
'Only Emperor loses a card; a claim is an exact one-for-one Semuta exchange');
|
|
assert.equal(seat.spice, player(f.beforeDeclaration, seat.id).spice);
|
|
assert.equal(seat.revived, player(f.beforeDeclaration, seat.id).revived);
|
|
assert.equal(seat.freeForcesRevived, player(f.beforeDeclaration, seat.id).freeForcesRevived);
|
|
}
|
|
assert.throws(() => applyAction(done, f.canceller, f.cancelAction), 'Paid original Karama cannot cancel again');
|
|
assert.throws(() => applyAction(done, f.owner, f.action), 'Retained printed fuel cannot replay this phase');
|
|
if (claim) assert.throws(() => applyAction(done, f.holder, f.action),
|
|
'Only Karama is recovered; Richese never acquires the canceled CHOAM power');
|
|
|
|
if (f.power === 'baliset') {
|
|
const movement = f.movement!;
|
|
const declined = applyAction(f.beforeDeclaration, f.owner, { type: 'decision', decline: true });
|
|
assert.deepEqual(resources(done), resources(declined),
|
|
'Canceling Baliset commits exactly the original unmodified reactive move');
|
|
assert.equal(player(done, f.target).moved, 1);
|
|
assert.equal(player(done, f.target).forces[movement.origin], player(movement.beforeMove, f.target).forces[movement.origin] - 1);
|
|
assert.equal(player(done, f.target).forces[movement.destination], 1);
|
|
assert.ok(!done.pendingChoamMove);
|
|
assert.deepEqual(viewGame(done, f.target).balisetRestrictions, []);
|
|
assert.throws(() => applyAction(done, f.target, movement.move), 'The original move cannot resume twice');
|
|
assert.equal(player(done, f.owner).reserves, player(movement.afterShipment, f.owner).reserves);
|
|
} else {
|
|
assert.deepEqual(resources(done), resources(f.beforeDeclaration),
|
|
'Canceling a power never applies its original resources or counters');
|
|
if (f.power === 'kulon') {
|
|
assert.equal(viewGame(done, f.owner).choamMovementBonus, 0);
|
|
assert.throws(() => applyAction(done, f.owner, f.movement!.move), 'Kulon cancellation gives no range-two bonus');
|
|
assert.equal(player(done, f.owner).reserves, player(f.movement!.afterShipment, f.owner).reserves);
|
|
} else if (f.power === 'laLaLa') {
|
|
const before = viewGame(f.beforeDeclaration, f.target).revival;
|
|
const after = viewGame(done, f.target).revival;
|
|
assert.ok(before.freeRemaining > 0);
|
|
assert.equal(after.freeRemaining, before.freeRemaining);
|
|
assert.equal(after.freeBlocked, false);
|
|
assert.equal(after.limit, before.limit);
|
|
for (const seat of done.players)
|
|
assert.equal(viewGame(done, seat.id).revival.freeRemaining, viewGame(f.beforeDeclaration, seat.id).revival.freeRemaining);
|
|
} else {
|
|
assert.deepEqual(viewGame(done, f.target).homeworlds!.worlds!.map(world => world.forces),
|
|
viewGame(f.beforeDeclaration, f.target).homeworlds!.worlds!.map(world => world.forces));
|
|
assert.equal(done.decision?.kind, 'choamMentat');
|
|
assert.equal(done.decision!.player, f.owner);
|
|
assert.equal(done.choamMentatPending, true);
|
|
assert.equal(viewGame(done, f.target).mentatVictoryPending, true);
|
|
assert.throws(() => applyAction(done, f.target, { type: 'decision', done: true }));
|
|
}
|
|
}
|
|
}
|
|
|
|
function consumeCancellationSuffix(f: SemutaHomeworldChoamKaramaFixture, state: Game) {
|
|
const after = f.power === 'gamont'
|
|
? applyAction(state, f.owner, { type: 'decision', done: true })
|
|
: advanceSemutaHomeworldChoam(state, game => game.phase > state.phase
|
|
&& !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard);
|
|
assert.equal(after.status, 'playing');
|
|
assert.equal(after.turn, state.turn + Number(f.power === 'gamont'));
|
|
assert.deepEqual(stock(after), stock(state));
|
|
assert.deepEqual(semutaHomeworldChoamReturnsForces(after), semutaHomeworldChoamReturnsForces(state));
|
|
for (const seat of after.players) {
|
|
const before = player(state, seat.id);
|
|
assert.deepEqual(seat.forces, before.forces, 'Native suffix cannot run a canceled move or return a second time');
|
|
assert.deepEqual(seat.elites, before.elites);
|
|
assert.equal(seat.reserves, before.reserves);
|
|
assert.equal(seat.tanks, before.tanks);
|
|
}
|
|
if (f.power === 'gamont') {
|
|
assert.equal(after.phase, 0);
|
|
assert.equal(after.choamMentatPending, false);
|
|
assert.throws(() => applyAction(after, f.owner, { type: 'decision', done: true }),
|
|
'The original CHOAM close is consumed exactly once');
|
|
} else assert.ok(after.phase >= (f.power === 'laLaLa' ? 5 : 6));
|
|
}
|
|
|
|
for (const advanced of [false, true]) for (const power of ['kulon', 'baliset', 'laLaLa', 'gamont'] as const) {
|
|
const label = `${advanced ? 'Advanced' : 'Basic'} Homeworld printed ${power} cancellation`;
|
|
for (const claim of [false, true]) void test(`${label}: Semuta ${claim ? 'claims' : 'passes'} only original Emperor Karama before one native denial suffix`, () => {
|
|
const f = createSemutaHomeworldChoamKaramaFixture(power, advanced);
|
|
if (f.movement) {
|
|
assert.equal(player(f.movement.afterShipment, f.owner).spice, player(f.movement.beforeShipment, f.owner).spice - 2);
|
|
assert.equal(player(f.movement.afterShipment, f.owner).reserves, player(f.movement.beforeShipment, f.owner).reserves - 1);
|
|
}
|
|
const pending = cancelSemutaHomeworldChoamKarama(f);
|
|
const cost = pending.pendingTreacheryDiscard;
|
|
assert.ok(cost, 'Actual Karama spending pauses before printed denial and original continuation');
|
|
assert.equal(pending.discard.filter(card => card.id === f.karama).length, 1);
|
|
assert.equal(player(pending, f.owner).hand.some(card => card.id === f.card), true);
|
|
assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true);
|
|
assert.deepEqual(resources(pending), resources(f.beforeDeclaration), 'Original move/return stays uncommitted during recovery');
|
|
assert.deepEqual(stock(pending), stock(f.beforeDeclaration));
|
|
assert.throws(() => applyAction(pending, f.canceller, f.cancelAction));
|
|
assert.throws(() => applyAction(pending, f.owner, f.action));
|
|
assert.throws(() => applyAction(pending, f.target, { type: 'ready' }));
|
|
assert.throws(() => applyAction(pending, f.owner, { type: 'decision', done: true }));
|
|
assert.throws(() => applyAction(pending, f.target, { type: 'semutaCommit', event: cost.batch.event }));
|
|
if (f.movement) assert.throws(() => applyAction(pending, f.target, f.movement!.move));
|
|
const done = reactSemutaHomeworldChoamKarama(structuredClone(pending), f.holder, claim);
|
|
assertCancellation(f, done, claim);
|
|
assert.throws(() => applyAction(done, f.holder, { type: 'semutaCommit', event: cost.batch.event }));
|
|
assert.throws(() => applyAction(done, f.target, { type: 'semutaPass', event: cost.batch.event }));
|
|
consumeCancellationSuffix(f, done);
|
|
});
|
|
|
|
void test(`${label}: actual entitlement is private and cancellation cost is a singleton exchange`, () => {
|
|
const f = createSemutaHomeworldChoamKaramaFixture(power, advanced, power === 'kulon' || power === 'baliset');
|
|
const pending = cancelSemutaHomeworldChoamKarama(f);
|
|
const absent = withoutSemutaHomeworldChoamMovementHolder(f.game, f.holder);
|
|
const neutral = cancelSemutaHomeworldChoamKarama(f, absent);
|
|
assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true);
|
|
assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false);
|
|
for (const seat of pending.players.filter(seat => seat.id !== f.holder)) {
|
|
assert.deepEqual(viewGame(pending, seat.id), viewGame(neutral, seat.id));
|
|
assert.equal(viewGame(pending, seat.id).semutaReaction?.canCommit, false);
|
|
assert.throws(() => applyAction(pending, seat.id,
|
|
{ type: 'semutaCommit', event: pending.pendingTreacheryDiscard!.batch.event }));
|
|
}
|
|
const done = reactSemutaHomeworldChoamKarama(pending, f.holder, true);
|
|
assertCancellation(f, done, true);
|
|
if (f.movement) assert.equal(player(done, f.holder).hand.length, handLimit(player(done, f.holder)),
|
|
'Printed Karama exchange remains legal at the native hand limit');
|
|
const noHolder = reactSemutaHomeworldChoamKarama(neutral, f.holder, false);
|
|
assert.equal(noHolder.discard.filter(card => card.id === f.karama).length, 1);
|
|
assert.deepEqual(resources(noHolder), resources(done));
|
|
consumeCancellationSuffix(f, done);
|
|
});
|
|
|
|
for (const difficulty of DIFFICULTIES) void test(`${difficulty} legally claims or passes ${label} then consumes original controls`, () => {
|
|
const f = createSemutaHomeworldChoamKaramaFixture(power, advanced);
|
|
let game = cancelSemutaHomeworldChoamKarama(f);
|
|
const own = viewGame(game, f.holder);
|
|
own.players.find(seat => seat.id === f.holder)!.bot = difficulty;
|
|
const action = botActions(own)[0];
|
|
assert.ok(action);
|
|
assert.ok(action.type === 'semutaCommit' || action.type === 'semutaPass');
|
|
game = applyAction(game, f.holder, action);
|
|
if (game.pendingTreacheryDiscard) game = reactSemutaHomeworldChoamKarama(game, f.holder, false);
|
|
assertCancellation(f, game, action.type === 'semutaCommit');
|
|
if (power === 'gamont') {
|
|
const closing = viewGame(game, f.owner);
|
|
closing.players.find(seat => seat.id === f.owner)!.bot = difficulty;
|
|
const close = botActions(closing)[0];
|
|
assert.ok(close);
|
|
const done = applyAction(game, f.owner, close);
|
|
assert.equal(done.phase, 0);
|
|
assert.equal(done.turn, game.turn + 1);
|
|
assert.equal(done.choamMentatPending, false);
|
|
assert.deepEqual(player(done, f.target).forces, player(game, f.target).forces);
|
|
assert.deepEqual(done.homeworlds?.custody, game.homeworlds?.custody);
|
|
assert.deepEqual(stock(done), stock(game));
|
|
} else consumeCancellationSuffix(f, game);
|
|
});
|
|
}
|