212 lines
12 KiB
TypeScript
212 lines
12 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { viewGame, type Action, type Game } from '../game/engine';
|
|
import {
|
|
createSemutaArrivalOverlapFixture as fixture, actSemutaArrivalOverlap as act,
|
|
player, inventory, passCurrentSemuta as pass, claimOverlapSemuta as claim,
|
|
openOverlapAmbassadorGrant as grant, openOverlapTerrorDiscard as terrorDiscard,
|
|
finishOverlapTerror as finishTerror, passArrivalOrder, type SemutaArrivalOverlapFixture,
|
|
} from './fixture-semuta-arrival-overlap';
|
|
|
|
function reject(game: Game, actor: string, action: Action) {
|
|
const before = structuredClone(game);
|
|
assert.throws(() => act(game, actor, action));
|
|
assert.deepEqual(game, before);
|
|
}
|
|
function arrived(game: Game, f: SemutaArrivalOverlapFixture) {
|
|
assert.equal(player(game, f.entrant).forces[f.location], 1);
|
|
assert.equal(player(game, f.entrant).shipped, true);
|
|
assert.equal(player(game, f.entrant).moved, f.arrival === 'movement' ? 1 : 0);
|
|
assert.equal(player(game, f.entrant).spice, f.balances[f.entrant] - Number(f.arrival === 'shipment'));
|
|
for (const p of game.players)
|
|
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, count) => sum + count, 0), 20);
|
|
assert.deepEqual(inventory(game), f.originalInventory);
|
|
}
|
|
function ambassadorDiscard(source: Game, f: SemutaArrivalOverlapFixture) {
|
|
const entry = source.pendingAmbassador; assert.equal(entry?.stage, 'cards');
|
|
const game = act(source, f.owner, { type: 'decision', event: entry!.event, cards: f.chosenCards });
|
|
assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'ambassador');
|
|
assert.equal(game.pendingTreacheryDiscard.reaction?.stage, 'offer');
|
|
assert.equal(game.pendingAmbassador, null);
|
|
assert.ok(game.pendingArrivalOverlap, 'The original ordered parent remains while the discard is read');
|
|
assert.deepEqual(viewGame(game, f.claimant).semutaReaction!.candidates, []);
|
|
arrived(game, f); return game;
|
|
}
|
|
function done(game: Game, f: SemutaArrivalOverlapFixture) {
|
|
assert.ok(!game.pendingArrivalOverlap && !game.pendingAmbassador && !game.pendingTerrorEntry);
|
|
assert.ok(!game.pendingTreacheryDiscard && !game.decision && !game.response);
|
|
assert.equal(game.active, f.entrant); arrived(game, f);
|
|
game = act(game, f.entrant, { type: 'endMovement' });
|
|
assert.ok(!game.movementRemaining?.includes(f.entrant));
|
|
assert.deepEqual(inventory(game), f.originalInventory);
|
|
}
|
|
|
|
for (const first of ['ambassador', 'terror'] as const)
|
|
for (const arrival of ['shipment', 'movement'] as const)
|
|
void test(`actual Basic ${arrival} snapshots both original native owners, ${first} first from real lobby circles`, () => {
|
|
const f = fixture({ effect: 'choam', first, arrival });
|
|
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, false);
|
|
assert.equal(f.afterSetup.status, 'playing'); assert.equal(f.game.turn, 2);
|
|
assert.equal(f.game.players.length, 5); assert.equal(f.game.semutaPreview, true);
|
|
assert.ok(f.actions.some(step => step.action.type === 'seatPosition'));
|
|
assert.equal(f.game.moritaniTerror!.placementTurn, 1);
|
|
assert.equal(f.game.ecazAmbassadors!.tokens.find(token => token.id === f.token)!.zone, 'placed');
|
|
assert.ok(f.game.players.every(p => !Object.keys(p.forces).some(key => key.startsWith(f.territory + ':'))));
|
|
let game = passArrivalOrder(act(f.game, f.arrivalStep.actor, f.arrivalStep.action));
|
|
const overlap = game.pendingArrivalOverlap; assert.ok(overlap);
|
|
assert.equal(overlap.first, first); assert.equal(overlap.stage, 'first');
|
|
assert.equal(overlap.entrant, f.entrant); assert.equal(overlap.cause, arrival);
|
|
assert.equal(overlap.ambassadorToken, f.token);
|
|
assert.deepEqual(overlap.terrorTokens, [f.terrorToken]);
|
|
assert.equal(game.decision?.player, first === 'ambassador' ? f.owner : f.moritani);
|
|
arrived(game, f);
|
|
reject(game, f.entrant, { type: 'endMovement' });
|
|
game = first === 'ambassador'
|
|
? act(game, f.owner, { type: 'decision', event: game.pendingAmbassador!.event, decline: true })
|
|
: act(game, f.moritani, { type: 'decision', decline: true });
|
|
assert.equal(game.pendingArrivalOverlap?.stage, 'second'); arrived(game, f);
|
|
game = first === 'ambassador'
|
|
? act(game, f.moritani, { type: 'decision', decline: true })
|
|
: act(game, f.owner, { type: 'decision', event: game.pendingAmbassador!.event, decline: true });
|
|
done(game, f);
|
|
});
|
|
|
|
void test('CHOAM first recovers one private card before Sabotage, pays once and cannot spend the same Semuta twice', () => {
|
|
const f = fixture({ effect: 'choam', first: 'ambassador', arrival: 'movement' });
|
|
let game = grant(f.game, f);
|
|
const beforeDiscard = structuredClone(game);
|
|
game = ambassadorDiscard(game, f);
|
|
const firstEvent = game.pendingTreacheryDiscard!.batch.event;
|
|
const overlap = structuredClone(game.pendingArrivalOverlap);
|
|
assert.equal(player(game, f.owner).spice, player(beforeDiscard, f.owner).spice + 6);
|
|
reject(game, f.moritani, { type: 'decision', reveal: true });
|
|
game = act(game, f.claimant, { type: 'semutaCommit', event: firstEvent });
|
|
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'select');
|
|
assert.deepEqual(viewGame(game, f.claimant).semutaReaction!.candidates.map(card => card.id), f.chosenCards);
|
|
assert.deepEqual(game.pendingArrivalOverlap, overlap);
|
|
for (const p of game.players.filter(p => p.id !== f.claimant))
|
|
assert.deepEqual(viewGame(game, p.id).semutaReaction!.candidates, []);
|
|
reject(game, f.owner, { type: 'semutaSelect', event: firstEvent, card: f.chosenCards[0] });
|
|
game = act(game, f.claimant, { type: 'semutaSelect', event: firstEvent, card: f.chosenCards[0] });
|
|
assert.equal(game.pendingArrivalOverlap?.stage, 'second');
|
|
assert.equal(game.pendingTerrorEntry?.stage, 'offer');
|
|
assert.equal(player(game, f.owner).spice, player(beforeDiscard, f.owner).spice + 6);
|
|
assert.ok(player(game, f.claimant).hand.some(card => card.id === f.chosenCards[0]));
|
|
game = terrorDiscard(game, f);
|
|
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
|
assert.notEqual(game.pendingTreacheryDiscard.batch.event, firstEvent);
|
|
for (const p of game.players) assert.equal(viewGame(game, p.id).semutaReaction!.canCommit, false);
|
|
reject(game, f.claimant, { type: 'semutaCommit', event: firstEvent });
|
|
game = finishTerror(pass(game), f, { give: true });
|
|
assert.ok(player(game, f.entrant).hand.some(card => card.id === f.gift));
|
|
assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
|
|
assert.equal(player(game, f.owner).spice, player(beforeDiscard, f.owner).spice + 6);
|
|
done(game, f);
|
|
});
|
|
|
|
void test('Robbery first passes its overflow before a full-hand Ixian claim and one original replacement', () => {
|
|
const f = fixture({ effect: 'ixians', first: 'terror', terror: 'robbery', fullHand: true });
|
|
let game = passArrivalOrder(act(f.game, f.arrivalStep.actor, f.arrivalStep.action));
|
|
const top = game.deck[0].id, size = game.deck.length;
|
|
game = terrorDiscard(game, f);
|
|
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
|
assert.equal(game.pendingTreacheryDiscard.continuation.kind, 'terrorDiscard');
|
|
assert.equal(game.pendingArrivalOverlap?.stage, 'first');
|
|
assert.equal(game.deck.length, size - 1);
|
|
assert.ok(player(game, f.moritani).hand.some(card => card.id === top));
|
|
const terrorEvent = game.pendingTreacheryDiscard.batch.event;
|
|
game = pass(game);
|
|
assert.equal(game.pendingArrivalOverlap?.stage, 'second');
|
|
assert.equal(game.pendingAmbassador?.stage, 'offer');
|
|
game = grant(game, f); const replacement = game.deck[0].id;
|
|
game = ambassadorDiscard(game, f);
|
|
reject(game, f.claimant, { type: 'semutaCommit', event: terrorEvent });
|
|
assert.equal(game.deck.length, size - 1, 'Ixian replacement has not drawn during the fresh discard');
|
|
game = claim(game, f.claimant, f.chosenCards[0]);
|
|
assert.equal(player(game, f.claimant).hand.length, 4);
|
|
assert.ok(player(game, f.claimant).hand.some(card => card.id === f.chosenCards[0]));
|
|
assert.equal(game.deck.length, size - 2);
|
|
assert.ok(player(game, f.owner).hand.some(card => card.id === replacement));
|
|
assert.ok(player(game, f.moritani).hand.some(card => card.id === top));
|
|
assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
|
|
done(game, f);
|
|
});
|
|
|
|
void test('Sabotage first spends Semuta on its victim before copied CHOAM creates a distinct discard', () => {
|
|
const f = fixture({ effect: 'choam', first: 'terror', arrival: 'movement', copied: true });
|
|
let game = passArrivalOrder(act(f.game, f.arrivalStep.actor, f.arrivalStep.action));
|
|
game = terrorDiscard(game, f);
|
|
const terrorEvent = game.pendingTreacheryDiscard!.batch.event;
|
|
assert.equal(game.pendingTreacheryDiscard!.batch.entries[0].card.id, f.entrantCard);
|
|
game = claim(game, f.claimant, f.entrantCard);
|
|
assert.equal(game.pendingArrivalOverlap?.stage, 'first');
|
|
game = finishTerror(game, f);
|
|
assert.equal(game.pendingArrivalOverlap?.stage, 'second');
|
|
game = grant(game, f);
|
|
assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === f.token)!.zone, 'removed');
|
|
game = ambassadorDiscard(game, f);
|
|
assert.notEqual(game.pendingTreacheryDiscard!.batch.event, terrorEvent);
|
|
for (const p of game.players) assert.equal(viewGame(game, p.id).semutaReaction!.canCommit, false);
|
|
game = pass(game);
|
|
assert.ok(player(game, f.claimant).hand.some(card => card.id === f.entrantCard));
|
|
assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
|
|
done(game, f);
|
|
});
|
|
|
|
void test('Ixian first passes before Robbery overflow recovery without replaying either draw', () => {
|
|
const f = fixture({ effect: 'ixians', first: 'ambassador', terror: 'robbery', arrival: 'movement' });
|
|
let game = grant(f.game, f);
|
|
const size = game.deck.length;
|
|
const ixianDraw = game.deck[0].id;
|
|
game = ambassadorDiscard(game, f);
|
|
const event = game.pendingTreacheryDiscard!.batch.event;
|
|
game = pass(game);
|
|
assert.equal(game.deck.length, size - 1);
|
|
assert.ok(player(game, f.owner).hand.some(card => card.id === ixianDraw));
|
|
const robberyDraw = game.deck[0].id;
|
|
game = terrorDiscard(game, f);
|
|
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
|
assert.notEqual(game.pendingTreacheryDiscard.batch.event, event);
|
|
assert.equal(game.deck.length, size - 2);
|
|
assert.ok(player(game, f.moritani).hand.some(card => card.id === robberyDraw));
|
|
game = claim(game, f.claimant, f.overflowDiscard);
|
|
assert.equal(game.deck.length, size - 2);
|
|
assert.ok(player(game, f.claimant).hand.some(card => card.id === f.overflowDiscard));
|
|
done(game, f);
|
|
});
|
|
|
|
void test('an empty copied CHOAM grant opens no empty fresh group and retains the second native Terror offer', () => {
|
|
const f = fixture({ effect: 'choam', first: 'ambassador', copied: true });
|
|
let game = grant(f.game, f); const spice = player(game, f.owner).spice;
|
|
game = act(game, f.owner, { type: 'decision', event: game.pendingAmbassador!.event, cards: [] });
|
|
assert.ok(!game.pendingTreacheryDiscard);
|
|
assert.equal(player(game, f.owner).spice, spice);
|
|
assert.equal(game.pendingArrivalOverlap?.stage, 'second');
|
|
assert.equal(game.pendingTerrorEntry?.stage, 'offer');
|
|
game = act(game, f.moritani, { type: 'decision', decline: true });
|
|
done(game, f);
|
|
});
|
|
|
|
void test('the Ambassador discard must keep the original shared entry sector and active owner', () => {
|
|
const f = fixture({ effect: 'choam', first: 'ambassador', arrival: 'movement' });
|
|
const game = ambassadorDiscard(grant(f.game, f), f);
|
|
const action: Action = { type: 'semutaPass', event: game.pendingTreacheryDiscard!.batch.event };
|
|
const moved = structuredClone(game), continuation = moved.pendingTreacheryDiscard!.continuation;
|
|
assert.equal(continuation.kind, 'ambassador');
|
|
if (continuation.kind === 'ambassador') continuation.entry.sector = 18;
|
|
reject(moved, f.claimant, action);
|
|
const advanced = structuredClone(game);
|
|
advanced.pendingArrivalOverlap!.stage = 'second';
|
|
reject(advanced, f.claimant, action);
|
|
});
|
|
|
|
void test('the Terror discard must retain the original shared entry amount before Semuta resumes it', () => {
|
|
const f = fixture({ effect: 'ixians', first: 'terror', terror: 'robbery' });
|
|
const game = terrorDiscard(act(f.game, f.arrivalStep.actor, f.arrivalStep.action), f);
|
|
const action: Action = { type: 'semutaPass', event: game.pendingTreacheryDiscard!.batch.event };
|
|
const changed = structuredClone(game), continuation = changed.pendingTreacheryDiscard!.continuation;
|
|
assert.equal(continuation.kind, 'terrorDiscard');
|
|
if (continuation.kind === 'terrorDiscard') continuation.entry.amount++;
|
|
reject(changed, f.claimant, action);
|
|
});
|