185 lines
9.5 KiB
TypeScript
185 lines
9.5 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
|
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
|
import { bribePromiseModeSupported } from '../game/bribe-promises';
|
|
import { revivalPromiseModeSupported } from '../game/revival-promises';
|
|
import { alliancePromiseModeSupported } from '../game/alliance-promises';
|
|
import { bidPromiseModeSupported } from '../game/bid-promises';
|
|
import { createSaphoTruthOrderFixture, playSaphoTruthOrder, finishTruthOrder,
|
|
finishOriginalTruthQuestions, inventory as saphoInventory } from './fixture-sapho-truth-order';
|
|
|
|
const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
|
|
const seat = (game: Game, id: string) => game.players.find(player => player.id === id)!;
|
|
|
|
function inventory(game: Game): string[] {
|
|
const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...game.players.flatMap(player => player.hand)];
|
|
const ids = cards.map(card => card.id).sort();
|
|
assert.equal(new Set(ids).size, ids.length);
|
|
return ids;
|
|
}
|
|
function take(game: Game, idOrEffect: string) {
|
|
const zones = [game.deck, game.discard, game.richeseCache!, ...game.players.map(player => player.hand)];
|
|
for (const zone of zones) {
|
|
const index = zone.findIndex(card => card.id === idOrEffect || card.effect === idOrEffect);
|
|
if (index >= 0) return zone.splice(index, 1)[0];
|
|
}
|
|
throw new Error(`Missing physical card ${idOrEffect}.`);
|
|
}
|
|
|
|
/** Use the actual public Advanced start and setup. For the specific discard
|
|
* window, transfer the two existing physical cards and stage Movement; this
|
|
* does not represent a natural auction or certify a complete game. */
|
|
function fixture() {
|
|
let game = createGame('ADVSEMUTAPUBLIC', newPlayer('a', 'Atreides', 'atreides'), true, ['choam']);
|
|
joinGame(game, newPlayer('r', 'Richese', 'richese'));
|
|
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
|
|
for (const player of game.players) game = act(game, player.id, {type:'ready'});
|
|
assert.throws(() => act(game, 'a', {type:'start'}), /Advanced.*preview/i);
|
|
game = act(game, 'a', {type:'start', advancedPreview:true});
|
|
assert.equal(game.status, 'setup');
|
|
assert.equal(game.semutaAdvancedOrdinaryPreview, true);
|
|
assert.equal(game.semutaPreview, undefined);
|
|
assert.equal(game.advanced, true);
|
|
for (let count = 0; game.status === 'setup' && count < 80; count++) {
|
|
let next: Game | undefined;
|
|
for (const player of game.players) {
|
|
const view = viewGame(game, player.id);
|
|
view.players.find(row => row.id === player.id)!.bot = 'Medium';
|
|
const action = botActions(view)[0];
|
|
if (action) { next = act(game, player.id, action); break; }
|
|
}
|
|
assert.ok(next, 'The genuine setup has a legal next step.');
|
|
game = next;
|
|
}
|
|
assert.equal(game.status, 'playing');
|
|
const before = inventory(game);
|
|
const semuta = take(game, SEMUTA_DRUG_ID);
|
|
const hajr = take(game, 'hajr');
|
|
seat(game, 'r').hand.push(semuta);
|
|
seat(game, 'a').hand.push(hajr);
|
|
assert.deepEqual(inventory(game), before);
|
|
Object.assign(game, {turn:2, phase:5, active:'a', storm:18, ready:[], response:null,
|
|
decision:null, phaseOpening:null, stormPending:null, movementRemaining:['a','r','e']});
|
|
// Advanced Hajr is available after the original Force Movement, not before.
|
|
game = act(game, 'a', {type:'move', from:'arrakeen:10', territory:'imperial_basin',
|
|
sector:9, amount:1, elite:0});
|
|
assert.equal(seat(game, 'a').moved, 1);
|
|
return {game, semuta, hajr, before};
|
|
}
|
|
|
|
for (const claim of [false, true]) void test(`public Advanced Richese offers Hajr to Semuta and ${claim ? 'claim' : 'pass'} resumes Movement`, () => {
|
|
const f = fixture(), original = structuredClone(f.game);
|
|
let game = act(f.game, 'a', {type:'card', card:f.hajr.id});
|
|
assert.deepEqual(f.game, original);
|
|
assert.ok(game.hajr.includes('a'));
|
|
const pending = game.pendingTreacheryDiscard; assert.ok(pending);
|
|
assert.equal(pending.continuation.kind, 'ordinaryCardDiscard');
|
|
assert.equal(pending.reaction?.stage, 'offer');
|
|
const event = pending.batch.event;
|
|
const reaction = viewGame(game, 'r').semutaReaction; assert.ok(reaction?.canCommit);
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const view = viewGame(game, 'r');
|
|
view.players.find(player => player.id === 'r')!.bot = difficulty;
|
|
assert.ok(botActions(view).some(action => action.type === 'semutaCommit' || action.type === 'semutaPass'), difficulty);
|
|
}
|
|
if (claim) {
|
|
game = act(game, 'r', {type:'semutaCommit', event});
|
|
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
|
game = act(game, 'r', {type:'semutaSelect', event, card:f.hajr.id});
|
|
} else {
|
|
for (const actor of ['a','r','e']) game = act(game, actor, {type:'semutaPass', event});
|
|
}
|
|
assert.equal(game.pendingTreacheryDiscard, null);
|
|
assert.ok(game.hajr.includes('a'));
|
|
assert.equal(seat(game, 'r').hand.some(card => card.id === f.hajr.id), claim);
|
|
assert.equal(game.discard.some(card => card.id === f.hajr.id), !claim);
|
|
assert.deepEqual(inventory(game), f.before);
|
|
game = act(game, 'a', {type:'endMovement'});
|
|
assert.ok(!game.movementRemaining?.includes('a'));
|
|
});
|
|
|
|
for (const effect of ['ghola', 'harvester', 'weather'] as const) {
|
|
for (const claim of [false, true]) void test(`public Advanced Richese offers ${effect} to Semuta and ${claim ? 'claim' : 'pass'} preserves its effect`, () => {
|
|
const f = fixture();
|
|
let game = f.game;
|
|
// Keep actual card inventory conserved while replacing the staged Hajr.
|
|
game.deck.push(take(game, f.hajr.id));
|
|
const card = take(game, effect);
|
|
seat(game, 'a').hand.push(card);
|
|
assert.deepEqual(inventory(game), f.before);
|
|
if (effect === 'ghola') {
|
|
Object.assign(game, {phase:4, active:null});
|
|
const player = seat(game, 'a');
|
|
assert.ok(player.forces['arrakeen:10'] > 0);
|
|
player.forces['arrakeen:10']--;
|
|
player.tanks++;
|
|
} else if (effect === 'harvester') {
|
|
Object.assign(game, {phase:1, active:null, nexus:false});
|
|
game.spiceWindow = {territory:'hagga_basin', sector:12, amount:6, harvested:false};
|
|
game.spice['hagga_basin:12'] = 6;
|
|
} else {
|
|
Object.assign(game, {phase:0, active:null, stormPending:2, stormResolution:null,
|
|
stormCardWindowOpen:true});
|
|
}
|
|
const before = {reserves:seat(game, 'a').reserves, tanks:seat(game, 'a').tanks};
|
|
const action: Action = effect === 'ghola' ? {type:'card', card:card.id, amount:1, elite:0}
|
|
: effect === 'weather' ? {type:'card', card:card.id, amount:1}
|
|
: {type:'card', card:card.id};
|
|
game = act(game, 'a', action);
|
|
const pending = game.pendingTreacheryDiscard; assert.ok(pending);
|
|
assert.equal(pending.continuation.kind, 'ordinaryCardDiscard');
|
|
assert.equal(pending.reaction?.stage, 'offer');
|
|
assert.equal(viewGame(game, 'r').semutaReaction?.canCommit, true);
|
|
if (effect === 'ghola') {
|
|
assert.equal(seat(game, 'a').tanks, before.tanks - 1);
|
|
assert.equal(seat(game, 'a').reserves, before.reserves + 1);
|
|
} else if (effect === 'harvester') {
|
|
assert.equal(game.spice['hagga_basin:12'], 12);
|
|
assert.equal(game.spiceWindow?.amount, 12);
|
|
assert.equal(game.spiceWindow?.harvested, true);
|
|
} else assert.equal(game.stormPending, 1);
|
|
const committed = structuredClone({phase:game.phase, stormPending:game.stormPending,
|
|
spice:game.spice, spiceWindow:game.spiceWindow, reserves:seat(game, 'a').reserves,
|
|
tanks:seat(game, 'a').tanks});
|
|
const event = pending.batch.event;
|
|
if (claim) {
|
|
game = act(game, 'r', {type:'semutaCommit', event});
|
|
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
|
game = act(game, 'r', {type:'semutaSelect', event, card:card.id});
|
|
} else for (const actor of ['a', 'r', 'e']) game = act(game, actor, {type:'semutaPass', event});
|
|
assert.equal(game.pendingTreacheryDiscard, null);
|
|
assert.deepEqual({phase:game.phase, stormPending:game.stormPending, spice:game.spice,
|
|
spiceWindow:game.spiceWindow, reserves:seat(game, 'a').reserves,
|
|
tanks:seat(game, 'a').tanks}, committed);
|
|
assert.equal(seat(game, 'r').hand.some(item => item.id === card.id), claim);
|
|
assert.equal(game.discard.some(item => item.id === card.id), !claim);
|
|
assert.deepEqual(inventory(game), f.before);
|
|
});
|
|
}
|
|
|
|
void test('Advanced ordinary Semuta entry preserves Sapho in a simultaneous Truthtrance order', () => {
|
|
const f = createSaphoTruthOrderFixture({advanced:true, mode:'first'});
|
|
assert.equal(f.game.semutaAdvancedOrdinaryPreview, true);
|
|
assert.equal(f.game.semutaPreview, undefined);
|
|
assert.ok(viewGame(f.game, f.owner).saphoTruthOrderChoices.some(option =>
|
|
option.card === f.card && option.mode === 'first'));
|
|
let game = playSaphoTruthOrder(f.game, f.owner, f.card, 'first');
|
|
assert.equal(game.pendingTreacheryDiscard ?? null, null);
|
|
game = finishTruthOrder(game);
|
|
assert.equal(game.truthtrance!.queue[0].player, f.owner);
|
|
game = finishOriginalTruthQuestions(game);
|
|
assert.equal(game.truthtrance, null);
|
|
assert.deepEqual(saphoInventory(game), f.originalCards);
|
|
});
|
|
|
|
void test('Advanced ordinary Semuta entry preserves existing bribe, revival, alliance and bid question admission', () => {
|
|
const {game} = fixture();
|
|
assert.equal(game.semutaAdvancedOrdinaryPreview, true);
|
|
assert.equal(game.semutaPreview, undefined);
|
|
for (const supported of [bribePromiseModeSupported, revivalPromiseModeSupported,
|
|
alliancePromiseModeSupported, bidPromiseModeSupported]) assert.equal(supported(game), true);
|
|
});
|