Files
Dune/tests/bot-advanced-source.test.ts

178 lines
10 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { splitLocation } from '../game/board';
import { advanceToForecast, createAdvancedSourceFixture, settleAdvancedSourceResponses } from './fixture-advanced-source';
void test('all four policies dial the actual first Storm then confirm the recorded result exactly once', () => {
for (const profile of DIFFICULTIES) {
const fixture = createAdvancedSourceFixture({ storm: 'first', fremen: false });
let game = fixture.game;
for (const dialer of fixture.game.stormDialers) {
const own = viewGame(game, dialer);
own.players.find(player => player.id === dialer)!.bot = profile;
const actions = botActions(own);
assert.equal(actions[0]?.type, 'stormDial');
assert.ok(Number.isInteger(actions[0].amount));
assert.ok(Number(actions[0].amount) >= 0 && Number(actions[0].amount) <= 20);
game = applyAction(game, dialer, actions[0]);
const submitted = viewGame(game, dialer);
submitted.players.find(player => player.id === dialer)!.bot = profile;
if (game.stormPending === null) assert.deepEqual(botActions(submitted), []);
}
assert.notEqual(game.stormPending, null);
const actor = game.players[0].id;
const own = viewGame(game, actor);
own.players.find(player => player.id === actor)!.bot = profile;
assert.deepEqual(botActions(own), [{ type: 'ready' }]);
game = applyAction(game, actor, botActions(own)[0]);
const confirmed = viewGame(game, actor);
confirmed.players.find(player => player.id === actor)!.bot = profile;
assert.deepEqual(botActions(confirmed), []);
}
});
void test('all four policies confirm subsequent Advanced public cards without a Fremen seat or phantom forecast', () => {
for (const profile of DIFFICULTIES) {
const fixture = createAdvancedSourceFixture({ storm: 'next', fremen: false });
let game = fixture.game;
assert.deepEqual(game.stormDialers, []);
assert.ok(Number(game.stormPending) >= 1 && Number(game.stormPending) <= 6);
const distance = game.stormPending;
for (const player of fixture.game.players) {
const own = viewGame(game, player.id);
own.players.find(seat => seat.id === player.id)!.bot = profile;
assert.equal(own.stormForecast, null);
assert.deepEqual(botActions(own), [{ type: 'ready' }]);
game = applyAction(game, player.id, botActions(own)[0]);
}
game = settleAdvancedSourceResponses(game);
assert.notEqual(game.phase, 0);
assert.equal(game.storm, ((fixture.game.storm - 1 + Number(distance)) % 18) + 1);
assert.equal(game.stormCard, null);
}
});
void test('all four policies finish actual later Basic dial protocols without drawing a card', () => {
for (const profile of DIFFICULTIES) {
const fixture = createAdvancedSourceFixture({ advanced: false, storm: 'next', fremen: false });
let game = fixture.game;
const amounts: number[] = [];
for (const dialer of fixture.game.stormDialers) {
const own = viewGame(game, dialer);
own.players.find(player => player.id === dialer)!.bot = profile;
const actions = botActions(own);
assert.equal(actions[0]?.type, 'stormDial');
const amount = Number(actions[0].amount);
assert.ok(Number.isInteger(amount) && amount >= 1 && amount <= 3);
amounts.push(amount);
game = applyAction(game, dialer, actions[0]);
}
assert.equal(game.stormPending, amounts.reduce((sum, amount) => sum + amount, 0));
assert.equal(game.stormMovementSource?.kind, 'dials');
assert.equal(game.stormCard, null);
const own = viewGame(game, game.players[0].id);
own.players[0].bot = profile;
assert.deepEqual(botActions(own), [{ type: 'ready' }]);
game = applyAction(game, own.me, botActions(own)[0]);
assert.ok(game.ready.includes(own.me));
}
});
void test('every legal policy retains a real end action when finishing destroys its own allied group', () => {
for (const profile of DIFFICULTIES) {
const fixture = createAdvancedSourceFixture({ alliance: 'newThisTurn' });
const own = viewGame(fixture.game, fixture.actor);
own.players.find(player => player.id === fixture.actor)!.bot = profile;
assert.ok(own.advancedAllySeparation?.territories.length);
const end = botActions(own).find(action => action.type === 'endMovement');
assert.ok(end, `${profile} must not deadlock on a mandatory consequence`);
const before = fixture.game.players.find(player => player.id === fixture.actor)!;
const allyBefore = fixture.game.players.find(player => player.id === fixture.ally)!;
const losses = Object.entries(before.forces).filter(([key]) => fixture.lossTerritories.includes(splitLocation(key).territory))
.reduce((sum, [, amount]) => sum + amount, 0);
const eliteLosses = Object.entries(before.elites?.forces ?? {}).filter(([key]) => fixture.lossTerritories.includes(splitLocation(key).territory))
.reduce((sum, [, amount]) => sum + amount, 0);
const result = applyAction(fixture.game, fixture.actor, end);
const after = result.players.find(player => player.id === fixture.actor)!;
assert.equal(after.tanks, before.tanks + losses);
assert.equal(after.elites?.tanks ?? 0, (before.elites?.tanks ?? 0) + eliteLosses);
assert.equal(after.reserves, before.reserves);
assert.equal(after.elites?.reserves, before.elites?.reserves);
assert.deepEqual(after.hand, before.hand);
for (const id of fixture.lossTerritories) {
assert.equal(Object.entries(after.forces).filter(([key]) => key.startsWith(`${id}:`)).reduce((sum, [, amount]) => sum + amount, 0), 0);
}
assert.deepEqual(result.players.find(player => player.id === fixture.ally)!.forces, allyBefore.forces);
const allyAfter = result.players.find(player => player.id === fixture.ally)!;
assert.equal(allyAfter.reserves, allyBefore.reserves);
assert.equal(allyAfter.tanks, allyBefore.tanks);
assert.deepEqual(allyAfter.elites, allyBefore.elites);
assert.deepEqual(allyAfter.advisors, allyBefore.advisors);
assert.deepEqual(allyAfter.hand, allyBefore.hand);
assert.notEqual(result.active, fixture.actor);
assert.equal(viewGame(result, fixture.actor).advancedAllySeparation, null);
}
});
void test('all four policies can finish with mandatory losses after their actual ordinary movement is exhausted', () => {
for (const profile of DIFFICULTIES) {
const fixture = createAdvancedSourceFixture({ alliance: 'newThisTurn' });
const own = viewGame(fixture.game, fixture.actor);
const actor = own.players.find(player => player.id === fixture.actor)!;
actor.bot = profile;
const move = botActions(own).find(action => action.type === 'move' &&
typeof action.from === 'string' && fixture.lossTerritories.includes(splitLocation(action.from).territory) &&
(actor.forces[String(action.from)] ?? 0) > 1);
assert.ok(move, `${profile} has an actual legal movement from its shared group`);
const game = settleAdvancedSourceResponses(applyAction(fixture.game, fixture.actor,
{ ...move, amount: 1, elite: 0 }));
const exhausted = viewGame(game, fixture.actor);
const afterMove = exhausted.players.find(player => player.id === fixture.actor)!;
afterMove.bot = profile;
assert.equal(afterMove.moved, afterMove.movesAllowed);
assert.ok(exhausted.advancedAllySeparation?.territories.length);
const end = botActions(exhausted).find(action => action.type === 'endMovement');
assert.ok(end, `${profile} retains forced legal end instead of an invalid-movement loop`);
const finished = applyAction(game, fixture.actor, end);
assert.ok(finished.players.find(player => player.id === fixture.actor)!.tanks > afterMove.tanks);
assert.notEqual(finished.active, fixture.actor);
}
});
void test('native higher-priority forecast response cannot fall through to Storm confirmation or movement for any profile', () => {
for (const profile of DIFFICULTIES) {
const fixture = createAdvancedSourceFixture({ storm: 'first', fremen: true, karama: true });
const pending = advanceToForecast(fixture.game);
assert.equal(pending.response?.kind, 'stormPeek');
const counter = pending.players.find(player => player.faction !== 'fremen' && player.hand.some(card => card.effect === 'karama'));
assert.ok(counter, 'the native deal supplies a genuinely eligible forecast counter');
const card = counter.hand.find(card => card.effect === 'karama')!;
assert.ok(viewGame(pending, counter.id).responseControls?.cancelCards.includes(card.id));
const forces = pending.players.map(player => ({id: player.id, forces: player.forces,
reserves: player.reserves, tanks: player.tanks, elites: player.elites ?? null, advisors: player.advisors ?? null}));
for (const player of pending.players) {
const own = viewGame(pending, player.id);
own.players.find(seat => seat.id === player.id)!.bot = profile;
assert.equal(own.stormForecast, null);
for (const action of botActions(own)) {
assert.ok(action.type === 'passResponse' || (action.type === 'card' && action.mode === 'cancel'));
const result = applyAction(pending, player.id, action);
assert.equal(result.phase, pending.phase);
assert.equal(result.storm, pending.storm);
assert.deepEqual(result.players.map(player => ({id: player.id, forces: player.forces,
reserves: player.reserves, tanks: player.tanks, elites: player.elites ?? null, advisors: player.advisors ?? null})), forces);
for (const seat of result.players) {
const before = pending.players.find(before => before.id === seat.id)!;
assert.deepEqual(seat.hand, before.hand.filter(held =>
!(action.type === 'card' && seat.id === player.id && held.id === action.card)));
}
if (action.type === 'card') assert.equal(result.discard.filter(discarded => discarded.id === action.card).length, 1);
else assert.deepEqual(result.discard, pending.discard);
}
}
}
});