145 lines
8.1 KiB
TypeScript
145 lines
8.1 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { createTruthTurnAmalFixture, truthTurnAmalInventory as inventory } from './fixture-truth-turn-amal';
|
|
|
|
const act = (game: Game, id: string, action: Action) => applyAction(game, id, action);
|
|
const seat = (game: Game, id: string) => {
|
|
const player = game.players.find(row => row.id === id); assert.ok(player); return player;
|
|
};
|
|
const copy = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
|
|
|
/** Original public Basic Ix Harkonnen/Atreides/Emperor setup opens the first
|
|
* real phase. Stage only the pair and conserved Amal/Karama custody; passes,
|
|
* gift, response and phase initialization use native actions. */
|
|
function fixture() {
|
|
const source = createTruthTurnAmalFixture();
|
|
let game = source.game;
|
|
const original = inventory(game);
|
|
seat(game, 'emperor').ally = 'atreides'; seat(game, 'atreides').ally = 'emperor';
|
|
const amalIndex = seat(game, 'emperor').hand.findIndex(card => card.effect === 'amal');
|
|
assert.ok(amalIndex >= 0);
|
|
seat(game, 'harkonnen').hand.push(seat(game, 'emperor').hand.splice(amalIndex, 1)[0]);
|
|
// A real held opposing Karama keeps the gift's response open; existing
|
|
// physical copies remain in their actual zones, never fabricated.
|
|
for (const player of game.players) {
|
|
const cards = player.hand.filter(card => card.effect === 'karama');
|
|
player.hand = player.hand.filter(card => card.effect !== 'karama');
|
|
game.deck.push(...cards);
|
|
}
|
|
const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
|
|
const [counter] = game.deck.splice(index, 1); seat(game, 'harkonnen').hand.push(counter);
|
|
assert.deepEqual(inventory(game), original);
|
|
assert.ok(game.phaseOpening);
|
|
game = act(game, 'emperor', {type:'ready'});
|
|
game = act(game, 'atreides', {type:'ready'});
|
|
assert.deepEqual(game.phaseOpening?.passed, ['emperor','atreides']);
|
|
return {game, original, counter:counter.id, amal:source.amal};
|
|
}
|
|
|
|
function passResponses(game: Game) {
|
|
for (let count = 0; game.response && count < 15; count++) {
|
|
const player = game.players.find(row => !game.response!.passed.includes(row.id)); assert.ok(player);
|
|
game = act(game, player.id, {type:'passResponse'});
|
|
}
|
|
assert.equal(game.response, null); return game;
|
|
}
|
|
|
|
for (const cancel of [false,true]) {
|
|
void test(`Basic Ix opening gift ${cancel ? 'cancellation' : 'allowance'} resumes exact passes through the original opening`, () => {
|
|
const f = fixture(), initial = f.game, opening = structuredClone(initial.phaseOpening);
|
|
const emperor = seat(initial, 'emperor').spice, ally = seat(initial, 'atreides').spice;
|
|
const phase = initial.phase, turn = initial.turn;
|
|
assert.deepEqual(viewGame(initial, 'emperor').emperorPhaseOpeningGift, {recipient:'atreides', maximum:emperor});
|
|
assert.equal(viewGame(initial, 'atreides').emperorPhaseOpeningGift, null);
|
|
assert.equal(viewGame(initial, 'harkonnen').emperorPhaseOpeningGift, null);
|
|
let game = act(initial, 'emperor', {type:'emperorGift', amount:3});
|
|
assert.equal(game.response?.kind, 'emperorGift');
|
|
assert.equal(game.phaseOpening, null);
|
|
assert.equal(seat(game, 'emperor').spice, emperor);
|
|
assert.equal(seat(game, 'atreides').spice, ally);
|
|
assert.equal(viewGame(game, 'emperor').response?.amount, 3);
|
|
assert.equal(viewGame(game, 'atreides').response?.amount, 3);
|
|
assert.equal(viewGame(game, 'harkonnen').response?.amount, undefined);
|
|
assert.equal(viewGame(game, 'emperor').emperorPhaseOpeningGift, null);
|
|
assert.throws(() => act(game, 'emperor', {type:'emperorGift', amount:1}));
|
|
game = cancel ? act(copy(game), 'harkonnen', {type:'card', card:f.counter, mode:'cancel'}) : passResponses(copy(game));
|
|
assert.equal(game.response, null);
|
|
assert.deepEqual(game.phaseOpening, opening);
|
|
assert.equal(game.phase, phase); assert.equal(game.turn, turn);
|
|
assert.equal(seat(game, 'emperor').spice, emperor - (cancel ? 0 : 3));
|
|
assert.equal(seat(game, 'atreides').spice, ally + (cancel ? 0 : 3));
|
|
assert.equal(game.discard.filter(card => card.id === f.counter).length, Number(cancel));
|
|
assert.throws(() => act(game, 'harkonnen', {type:'passResponse'}));
|
|
|
|
// The unpassed Harkonnen can still play the original physical Amal.
|
|
const beforeAmal = game.players.map(player => player.spice);
|
|
game = act(game, 'harkonnen', {type:'card', card:f.amal});
|
|
assert.deepEqual(game.players.map(player => player.spice), beforeAmal.map(amount => Math.floor(amount / 2)));
|
|
assert.deepEqual(game.phaseOpening?.passed, []);
|
|
assert.equal(game.discard.filter(card => card.id === f.amal).length, 1);
|
|
for (const id of ['emperor','atreides','harkonnen']) game = act(game, id, {type:'ready'});
|
|
assert.equal(game.phaseOpening, null);
|
|
assert.equal(game.phase, phase); assert.equal(game.turn, turn);
|
|
assert.deepEqual(game.players.map(player => player.spice), beforeAmal.map(amount => Math.floor(amount / 2)));
|
|
assert.throws(() => act(game, 'harkonnen', {type:'card', card:f.amal}));
|
|
assert.deepEqual(inventory(game), f.original);
|
|
});
|
|
}
|
|
|
|
void test('all minimal bots can legally respond to an opening gift without losing its parent', () => {
|
|
const f = fixture(), opening = structuredClone(f.game.phaseOpening);
|
|
const pending = act(f.game, 'emperor', {type:'emperorGift', amount:1});
|
|
for (const difficulty of DIFFICULTIES) {
|
|
let game = copy(pending);
|
|
const view = viewGame(game, 'harkonnen'); view.players.find(player => player.id === 'harkonnen')!.bot = difficulty;
|
|
const action = botActions(view).find(item => item.type === 'passResponse' || item.type === 'card');
|
|
assert.ok(action); game = act(game, 'harkonnen', action);
|
|
game = passResponses(game);
|
|
assert.deepEqual(game.phaseOpening, opening);
|
|
assert.equal(game.phase, f.game.phase);
|
|
}
|
|
});
|
|
|
|
void test('invalid opening gifts and a mismatched original opening cannot commit', () => {
|
|
const f = fixture(), before = copy(f.game);
|
|
for (const [actor, action] of [
|
|
['atreides', {type:'emperorGift', amount:1}],
|
|
['emperor', {type:'emperorGift', amount:0}],
|
|
['emperor', {type:'emperorGift', amount:11}],
|
|
] as [string, Action][]) {
|
|
assert.throws(() => act(f.game, actor, action));
|
|
assert.deepEqual(f.game, before);
|
|
}
|
|
const unallied = copy(f.game);
|
|
seat(unallied, 'emperor').ally = null; seat(unallied, 'atreides').ally = null;
|
|
assert.equal(viewGame(unallied, 'emperor').emperorPhaseOpeningGift, null);
|
|
assert.throws(() => act(unallied, 'emperor', {type:'emperorGift', amount:1}));
|
|
const pending = act(f.game, 'emperor', {type:'emperorGift', amount:1});
|
|
const altered = copy(pending);
|
|
altered.response!.emperorPhaseOpeningGift!.opening.passed = [];
|
|
assert.throws(() => viewGame(altered, 'emperor'), /original phase opening/);
|
|
assert.throws(() => act(altered, 'harkonnen', {type:'passResponse'}), /original phase opening/);
|
|
});
|
|
|
|
void test('a deferred Mentat initialization follows a gift and credits the original bribe once', () => {
|
|
const f = createTruthTurnAmalFixture({phase:8, incoming:4});
|
|
let game = f.game;
|
|
seat(game, 'emperor').ally = 'atreides'; seat(game, 'atreides').ally = 'emperor';
|
|
assert.equal(game.phaseOpening?.initialize, true);
|
|
assert.equal(seat(game, 'emperor').spice, 10); assert.equal(seat(game, 'emperor').bribes, 4);
|
|
game = act(game, 'emperor', {type:'ready'});
|
|
assert.deepEqual(viewGame(game, 'emperor').emperorPhaseOpeningGift, {recipient:'atreides', maximum:10});
|
|
assert.throws(() => act(game, 'emperor', {type:'emperorGift', amount:11}));
|
|
game = act(game, 'emperor', {type:'emperorGift', amount:3});
|
|
game = passResponses(game);
|
|
assert.deepEqual(game.phaseOpening, {passed:['emperor'], initialize:true});
|
|
assert.equal(seat(game, 'emperor').spice, 7); assert.equal(seat(game, 'emperor').bribes, 4);
|
|
game = act(game, 'atreides', {type:'ready'});
|
|
game = act(game, 'harkonnen', {type:'ready'});
|
|
assert.equal(game.phaseOpening, null);
|
|
assert.equal(seat(game, 'emperor').spice, 11); assert.equal(seat(game, 'emperor').bribes, 0);
|
|
});
|