Files
Dune/tests/ecaz-duke-bots.test.ts

249 lines
8.2 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
viewGame,
type Game,
type GameView,
} from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { baseDeck } from '../game/cards';
import {
createAmbassadors,
placeAmbassador,
copiedAmbassadorEffects,
} from '../game/ecaz-ambassadors';
import { createDukeVidal, acquireDuke } from '../game/duke-vidal';
function fixture(advanced = false, allied = false) {
const g = createGame(
'ECAZDUKEBOTS',
newPlayer('ec', 'Ecaz', 'ecaz'),
advanced,
);
g.players.push(
newPlayer('in', 'Entrant', 'atreides'),
newPlayer('al', 'Emperor', 'emperor'),
);
Object.assign(g, {
status: 'playing',
phase: 5,
turn: 2,
storm: 18,
active: 'in',
order: ['in', 'ec', 'al'],
movementRemaining: ['in', 'ec', 'al'],
deck: baseDeck(),
dukeVidal: createDukeVidal(),
});
for (const p of g.players)
Object.assign(p, {
hand: [],
forces: {},
reserves: 20,
spice: 20,
traitors: [],
traitorChoices: [],
});
if (allied) {
g.players[0].ally = 'al';
g.players[2].ally = 'ec';
}
// Construct the existing placed-component checkpoint; the entry and every bot
// resolution below use actual public actions, never a manually built offer.
const inventory = createAmbassadors(() => 0);
const token = inventory.tokens.find((t) => t.effect === 'ecaz')!;
g.ecazAmbassadors = placeAmbassador(inventory, token.id, {
turn: 1,
availableSpice: 20,
destination: {
id: 'arrakeen',
stronghold: true,
inStorm: false,
allowed: true,
},
}).state;
return g;
}
function enter(g: Game) {
return applyAction(g, 'in', {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount: 2,
});
}
function botView(g: Game, level: (typeof DIFFICULTIES)[number], id = 'ec') {
const view = viewGame(g, id);
view.players.find((p) => p.id === id)!.bot = level;
return view;
}
function action(g: Game, level: (typeof DIFFICULTIES)[number]) {
const view = botView(g, level),
before = structuredClone(view);
const actions = botActions(view);
assert.equal(actions.length, 1);
assert.deepEqual(view, before, 'bot policy does not mutate its projection');
assert.equal(actions[0].event, g.pendingAmbassador!.event);
return actions[0];
}
for (const level of DIFFICULTIES)
void test(`${level} acquires for Ecaz in Basic/Advanced when an existing alliance rules out a new offer`, () => {
for (const advanced of [false, true])
for (const allied of [false, true]) {
const initial = fixture(advanced, allied);
if (!allied) {
initial.players[1].ally = 'al';
initial.players[2].ally = 'in';
}
const g = enter(initial);
assert.deepEqual(viewGame(g, 'ec').ambassadorEntry!.dukeAcquisition, {
blocked: null,
});
const selected = action(g, level);
assert.deepEqual(selected, {
type: 'decision',
event: g.pendingAmbassador!.event,
trigger: true,
beneficiary: 'ec',
choice: 'duke',
});
const done = applyAction(
JSON.parse(JSON.stringify(g)) as Game,
'ec',
selected,
);
assert.equal(done.pendingAmbassador, null);
assert.equal(done.dukeVidal!.controller, 'ec');
assert.equal(done.dukeVidal!.source, 'ecaz');
assert.equal(done.dukeVidal!.acquiredTurn, 2);
assert.deepEqual(done.dukeVidal!.leader, g.dukeVidal!.leader);
assert.equal(
done.ecazAmbassadors!.tokens.find((t) => t.effect === 'ecaz')!.zone,
'supply',
);
assert.deepEqual(
done.ecazAmbassadors!.cohort,
g.ecazAmbassadors!.cohort,
);
assert.deepEqual(
done.players,
g.players,
'no force, spice, hand, alliance or native-leader changes',
);
assert.equal(done.active, g.active);
assert.deepEqual(done.movementRemaining, g.movementRemaining);
}
});
const unavailable: [string, (g: Game) => void][] = [
[
'Duke in Tanks',
(g) => {
g.dukeVidal!.leader.dead = true;
g.dukeVidal!.leader.deaths = 1;
},
],
[
'Advanced Harkonnen table remains gated',
(g) => {
g.advanced = true;
g.players[2] = newPlayer('al', 'Harkonnen', 'harkonnen');
Object.assign(g.players[2], {
hand: [],
forces: {},
reserves: 20,
spice: 20,
traitors: [],
});
},
],
];
for (const [name, change] of unavailable)
void test(`all four profiles leave the token in place when already allied and ${name}`, () => {
const state = fixture();
change(state);
state.players[0].ally = 'al';
state.players[2].ally = 'ec';
const g = enter(state);
assert.ok(viewGame(g, 'ec').ambassadorEntry!.dukeAcquisition!.blocked);
for (const level of DIFFICULTIES) {
const selected = action(g, level);
assert.deepEqual(selected, {
type: 'decision',
event: g.pendingAmbassador!.event,
decline: true,
});
const done = applyAction(g, 'ec', selected);
assert.equal(done.pendingAmbassador, null);
assert.deepEqual(done.dukeVidal, g.dukeVidal);
assert.deepEqual(done.ecazAmbassadors, g.ecazAmbassadors);
}
});
void test('only the actual owner gets the choice, and bots cannot acquire from an unrelated seat', () => {
const g = enter(fixture(true, true));
for (const id of ['in', 'al'])
for (const level of DIFFICULTIES) {
assert.equal(viewGame(g, id).ambassadorEntry!.dukeAcquisition, null);
assert.deepEqual(botActions(botView(g, level, id)), []);
}
});
void test('all profiles share an Ecaz-held Duke with its offered existing ally', () => {
const state = fixture(false, true);
state.dukeVidal = acquireDuke(state.dukeVidal!, 'ec', 1, 'ecaz');
const g = enter(state);
for (const level of DIFFICULTIES) {
const selected = action(g, level);
assert.deepEqual(selected, { type: 'decision', event: g.pendingAmbassador!.event,
trigger: true, beneficiary: 'al', choice: 'duke' });
const done = applyAction(g, 'ec', selected);
assert.equal(done.dukeVidal!.controller, 'al');
assert.equal(done.dukeVidal!.source, 'ecaz');
assert.deepEqual(done.players, g.players);
assert.equal(done.ecazAmbassadors!.tokens.find(token => token.effect === 'ecaz')!.zone, 'supply');
assert.deepEqual(done.ecazAmbassadors!.cohort, g.ecazAmbassadors!.cohort);
}
});
void test('all profiles rely on offered recipient descriptors, with safe decline when all are missing', () => {
const g = enter(fixture(false, true));
for (const level of DIFFICULTIES) {
const view = botView(g, level);
Object.defineProperty(view, 'dukeVidal', {
get() {
throw Error('Policy read raw Duke custody');
},
});
assert.equal(botActions(view)[0].choice, 'duke');
view.ambassadorEntry!.dukeAcquisition = null;
assert.equal(botActions(view)[0].choice, 'duke');
view.ambassadorEntry!.beneficiaries = [];
assert.deepEqual(botActions(view), [
{ type: 'decision', event: g.pendingAmbassador!.event, decline: true },
]);
}
});
void test('private opponent hands, resources and traitors do not change acquisition choices or become projected evidence', () => {
const g = enter(fixture(false, true));
const changed = structuredClone(g);
changed.players[1].hand.push(changed.deck.shift()!);
changed.players[1].spice = 1;
changed.players[1].traitors = [changed.players[2].leaders[0].id];
for (const level of DIFFICULTIES) {
assert.deepEqual(action(g, level), action(changed, level));
const view: GameView = botView(changed, level);
assert.equal(view.players[1].hand, undefined);
assert.equal(view.players[1].spice, undefined);
assert.equal(view.players[1].traitors, undefined);
assert.deepEqual(view.ambassadorEntry!.dukeAcquisition, { blocked: null });
}
});
void test('enabling direct Ecaz acquisition never adds the reusable token to BG copy candidates', () => {
const state = fixture().ecazAmbassadors!;
assert.ok(!copiedAmbassadorEffects(state).includes('ecaz'));
});