249 lines
8.2 KiB
TypeScript
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'));
|
|
});
|