Files
Dune/tests/emperor-phase-opening-gift.test.ts
T
Quintine d4abc6bb83
Verify and publish checkpoint container / container (push) Canceled after 0s
Allow Emperor gifts during Basic Ix phase openings
2026-10-29 06:33:41 +11:00

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);
});