149 lines
9.2 KiB
TypeScript
149 lines
9.2 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { applyAction, viewGame, type Game } from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { placeAmbassador } from '../game/ecaz-ambassadors';
|
|
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
|
import { basicSemutaAmbassadorInventory as inventory } from './fixture-basic-semuta-ambassador';
|
|
import { semutaDiscoverySkillsOverlapFixture, act, clean, player } from './semuta-discovery-skills-overlap-fixture';
|
|
|
|
/** Stage physical cards and the original Ambassador cohort on a fresh public
|
|
* Discovery + Skills setup. No cards or tokens are created or destroyed. The
|
|
* arrival, Ambassador decision, discard and reaction are actual game actions. */
|
|
function fixture(effect: 'choam' | 'ixians') {
|
|
const source = semutaDiscoverySkillsOverlapFixture(false);
|
|
let game = structuredClone(source.beforeMarkers);
|
|
const originalCards = inventory(game);
|
|
const ambassadors = game.ecazAmbassadors!;
|
|
const token = ambassadors.tokens.find(row => row.effect === effect)!;
|
|
if (token.zone === 'pool') {
|
|
const replacement = ambassadors.tokens.find(row => row.zone === 'supply' && row.effect !== 'ecaz')!;
|
|
assert.ok(replacement);
|
|
const position = ambassadors.cohort.indexOf(replacement.id);
|
|
assert.ok(position >= 0);
|
|
ambassadors.cohort[position] = token.id;
|
|
token.zone = 'supply'; replacement.zone = 'pool';
|
|
}
|
|
assert.equal(token.zone, 'supply');
|
|
const originalTokenIds = ambassadors.tokens.map(row => row.id).sort();
|
|
// Choose Semuta from the actual physical cache, deck, discard or a hand;
|
|
// exchange it with an existing Richese card if necessary.
|
|
const holders = [game.deck, game.discard, game.richeseCache!, ...game.players.map(row => row.hand)];
|
|
const richese = player(game, 'richese');
|
|
const semutaSource = holders.find(cards => cards.some(card => card.id === SEMUTA_DRUG_ID));
|
|
assert.ok(semutaSource);
|
|
if (semutaSource !== richese.hand) {
|
|
const index = semutaSource.findIndex(card => card.id === SEMUTA_DRUG_ID);
|
|
const [semuta] = semutaSource.splice(index, 1);
|
|
if (richese.hand.length >= 4) semutaSource.push(richese.hand.pop()!);
|
|
richese.hand.push(semuta);
|
|
}
|
|
const ecaz = player(game, 'ecaz');
|
|
while (ecaz.hand.length < 2) { const card = game.deck.shift(); assert.ok(card); ecaz.hand.push(card); }
|
|
assert.deepEqual(inventory(game), originalCards);
|
|
const territory = source.destination, sector = source.sector;
|
|
assert.notEqual(game.storm, sector);
|
|
game.ecazAmbassadors = placeAmbassador(ambassadors, token.id, {
|
|
turn: game.turn, availableSpice: ecaz.spice,
|
|
destination: { id: territory, stronghold: true, inStorm: false, allowed: true },
|
|
}).state;
|
|
// A normal shipment followed by actual movement into the stronghold tests
|
|
// resumption of the original movement instead of merely an entry snapshot.
|
|
game = act(game, 'guild', {type:'ship', territory:'habbanya_ridge_flat', sector, amount:1, elite:0, allyPayment:0});
|
|
game = source.advance(game, state => state.phase === 5 && state.active === 'guild' && clean(state));
|
|
game = act(game, 'guild', {type:'move', from:`habbanya_ridge_flat:${sector}`, territory, sector, amount:1});
|
|
game = source.advance(game, state => !state.response && state.pendingAmbassador?.stage === 'offer');
|
|
const event = game.pendingAmbassador!.event;
|
|
assert.equal(game.pendingAmbassador!.arrivalCause, 'movement');
|
|
assert.equal(game.pendingAmbassador!.token, token.id);
|
|
game = act(game, 'ecaz', {type:'decision', event, trigger:true, beneficiary:'ecaz', choice:'effect'});
|
|
assert.equal(game.pendingAmbassador?.stage, 'cards');
|
|
assert.equal(game.pendingAmbassador?.effect, effect);
|
|
const chosen = player(game, 'ecaz').hand.slice(0, effect === 'choam' ? 2 : 1).map(card => card.id);
|
|
assert.equal(chosen.length, effect === 'choam' ? 2 : 1);
|
|
return {game, event, chosen, originalCards, originalTokenIds, advance:source.advance, location:`${territory}:${sector}`};
|
|
}
|
|
|
|
for (const effect of ['choam', 'ixians'] as const) void test(
|
|
`Basic Discovery + Skills native ${effect} Ambassador discard offers Semuta and resumes movement`, () => {
|
|
const f = fixture(effect), source = structuredClone(f.game);
|
|
const ecazSpice = player(source, 'ecaz').spice;
|
|
const pending = act(source, 'ecaz', {type:'decision', event:f.event, cards:f.chosen});
|
|
assert.deepEqual(source, f.game);
|
|
const receipt = pending.pendingTreacheryDiscard!;
|
|
assert.equal(receipt?.reaction?.stage, 'offer');
|
|
assert.equal(receipt.continuation.kind, 'ambassador');
|
|
assert.equal(receipt.batch.cause, `ambassador:${effect}`);
|
|
assert.deepEqual(receipt.batch.entries.map(row => row.card.id), f.chosen);
|
|
assert.ok(receipt.batch.entries.every(row => !row.publicFace && row.discardedBy === 'ecaz'));
|
|
assert.equal(player(pending, 'ecaz').spice, ecazSpice + (effect === 'choam' ? 6 : 0));
|
|
assert.deepEqual(pending.deck, source.deck);
|
|
const event = receipt.batch.event;
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const view = viewGame(pending, 'richese');
|
|
view.players.find(row => row.id === 'richese')!.bot = difficulty;
|
|
const choice = botActions(view).find(row => row.type === 'semutaCommit' || row.type === 'semutaPass');
|
|
assert.ok(choice, difficulty);
|
|
assert.doesNotThrow(() => applyAction(pending, 'richese', choice), difficulty);
|
|
}
|
|
for (const claim of [false, true]) {
|
|
let resumed: Game = structuredClone(pending);
|
|
if (claim) {
|
|
resumed = act(resumed, 'richese', {type:'semutaCommit', event});
|
|
if (resumed.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
|
resumed = act(resumed, 'richese', {type:'semutaSelect', event, card:f.chosen[0]});
|
|
} else for (const seat of resumed.players) resumed = act(resumed, seat.id, {type:'semutaPass', event});
|
|
assert.equal(resumed.pendingTreacheryDiscard, null);
|
|
assert.equal(resumed.pendingAmbassador, null);
|
|
assert.equal(resumed.active, 'guild');
|
|
assert.equal(player(resumed, 'guild').moved, 1);
|
|
assert.equal(player(resumed, 'guild').forces[f.location], 1);
|
|
assert.equal(player(resumed, 'ecaz').spice, ecazSpice + (effect === 'choam' ? 6 : 0));
|
|
assert.equal(player(resumed, 'richese').hand.some(card => card.id === f.chosen[0]), claim);
|
|
assert.equal(resumed.discard.some(card => card.id === SEMUTA_DRUG_ID), claim);
|
|
assert.equal(resumed.discard.some(card => card.id === f.chosen[0]), !claim);
|
|
if (effect === 'ixians') {
|
|
assert.ok(player(resumed, 'ecaz').hand.some(card => card.id === source.deck[0].id));
|
|
assert.deepEqual(resumed.deck.map(card => card.id), source.deck.slice(1).map(card => card.id));
|
|
} else assert.deepEqual(resumed.deck, source.deck);
|
|
assert.deepEqual(inventory(resumed), f.originalCards);
|
|
assert.deepEqual(resumed.ecazAmbassadors!.tokens.map(row => row.id).sort(), f.originalTokenIds);
|
|
resumed = f.advance(resumed, state => clean(state));
|
|
resumed = act(resumed, 'guild', {type:'endMovement'});
|
|
assert.ok(!resumed.movementRemaining?.includes('guild'));
|
|
assert.deepEqual(inventory(resumed), f.originalCards);
|
|
}
|
|
});
|
|
|
|
for (const effect of ['choam', 'ixians'] as const) void test(
|
|
`legacy Basic Discovery + Skills ${effect} Ambassador receipt without a reaction continues automatically`, () => {
|
|
const f = fixture(effect), originalSpice = player(f.game, 'ecaz').spice;
|
|
const pending = act(f.game, 'ecaz', {type:'decision', event:f.event, cards:f.chosen});
|
|
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'ambassador');
|
|
assert.ok(pending.pendingTreacheryDiscard?.reaction);
|
|
// Model the serialized shape of an older already-committed receipt; the
|
|
// actual discard, payout, token and continuation are left untouched.
|
|
const legacy = structuredClone(pending);
|
|
delete legacy.pendingTreacheryDiscard!.reaction;
|
|
assert.equal(viewGame(legacy, 'guild').automaticContinuationPending, true);
|
|
const resumed = applyAction(legacy, 'guild', {type:'advanceBots'});
|
|
assert.equal(resumed.pendingTreacheryDiscard, null);
|
|
assert.equal(resumed.pendingAmbassador, null);
|
|
assert.equal(resumed.active, 'guild');
|
|
assert.equal(player(resumed, 'guild').moved, 1);
|
|
assert.equal(player(resumed, 'guild').forces[f.location], 1);
|
|
assert.equal(player(resumed, 'ecaz').spice, originalSpice + (effect === 'choam' ? 6 : 0));
|
|
assert.ok(player(resumed, 'richese').hand.some(card => card.id === SEMUTA_DRUG_ID));
|
|
assert.ok(f.chosen.every(id => resumed.discard.filter(card => card.id === id).length === 1));
|
|
if (effect === 'ixians') {
|
|
assert.ok(player(resumed, 'ecaz').hand.some(card => card.id === f.game.deck[0].id));
|
|
assert.deepEqual(resumed.deck.map(card => card.id), f.game.deck.slice(1).map(card => card.id));
|
|
} else assert.deepEqual(resumed.deck, f.game.deck);
|
|
assert.deepEqual(inventory(resumed), f.originalCards);
|
|
assert.deepEqual(resumed.ecazAmbassadors!.tokens.map(row => row.id).sort(), f.originalTokenIds);
|
|
const finished = act(f.advance(resumed, state => clean(state)), 'guild', {type:'endMovement'});
|
|
assert.ok(!finished.movementRemaining?.includes('guild'));
|
|
assert.deepEqual(inventory(finished), f.originalCards);
|
|
});
|