Files
Dune/tests/fremen-ambassador-bots.test.ts
T

317 lines
10 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, type Difficulty } from '../game/bot-profiles';
import { baseDeck } from '../game/cards';
import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors';
import {
createRicheseNoField,
deployRicheseNoField,
} from '../game/richese-no-field';
import type { FactionId } from '../game/catalog';
function fixture(faction: FactionId = 'ixians', copy = false) {
const g = createGame('FAMBOTS', newPlayer('ec', 'Ecaz', 'ecaz'), true, [
'ecaz',
'ix',
'choam',
]);
g.players.push(
newPlayer('in', 'Entrant', 'emperor'),
newPlayer('al', 'Ally', faction),
);
Object.assign(g, {
status: 'playing',
phase: 5,
turn: 2,
storm: 18,
active: 'in',
order: ['in', 'ec', 'al'],
movementRemaining: ['in', 'ec', 'al'],
deck: baseDeck(),
});
for (const p of g.players)
Object.assign(p, {
hand: [],
forces: {},
elites: undefined,
reserves: 20,
tanks: 0,
spice: 10,
traitors: [],
traitorChoices: [],
});
g.players[0].ally = 'al';
g.players[2].ally = 'ec';
const inventory = createAmbassadors(() => 0);
const effect = copy ? 'beneGesserit' : 'fremen';
const token = inventory.tokens.find((t) => t.effect === effect)!;
inventory.cohort = [
token.id,
...inventory.tokens
.filter(
(t) =>
t.effect !== 'ecaz' && t.effect !== 'fremen' && t.id !== token.id,
)
.slice(0, 4)
.map((t) => t.id),
];
for (const t of inventory.tokens) {
t.zone =
t.effect === 'ecaz' || inventory.cohort.includes(t.id)
? 'supply'
: 'pool';
t.location = null;
}
g.ecazAmbassadors = placeAmbassador(inventory, token.id, {
turn: 1,
availableSpice: 10,
destination: {
id: 'arrakeen',
stronghold: true,
inStorm: false,
allowed: true,
},
}).state;
return g;
}
function army(g: Game, id: string, forces: Record<string, number>) {
const p = g.players.find((p) => p.id === id)!;
p.forces = forces;
p.reserves = 20 - Object.values(forces).reduce((n, amount) => n + amount, 0);
}
function enter(g: Game) {
return applyAction(g, 'in', {
type: 'ship',
amount: 1,
territory: 'arrakeen',
sector: 10,
});
}
// Entry IDs are generated by the actual arrival; obtain and use one arrival only.
function entered(g: Game, beneficiary: string, copy = false) {
let out = enter(g);
out = applyAction(out, 'ec', {
type: 'decision',
event: out.pendingAmbassador!.event,
trigger: true,
beneficiary,
});
if (copy)
out = applyAction(out, beneficiary, {
type: 'decision',
event: out.pendingAmbassador!.event,
effect: 'fremen',
});
return out;
}
function view(g: Game, id: string, level: Difficulty) {
const v = viewGame(g, id);
v.players.find((p) => p.id === id)!.bot = level;
return v;
}
function selected(v: GameView) {
const before = structuredClone(v);
const actions = botActions(v);
assert.deepEqual(v, before);
assert.equal(actions.length, 1);
return actions[0];
}
for (const level of DIFFICULTIES) {
void test(`${level} relocates own multi-sector forces through the real event without spending ordinary movement`, () => {
const initial = fixture();
army(initial, 'ec', { 'wind_pass:14': 4, 'wind_pass:15': 3 });
initial.players[0].shipped = true;
initial.players[0].moved = 1;
initial.hajr = ['ec'];
const g = entered(initial, 'ec'),
before = structuredClone(g);
assert.equal(g.pendingAmbassador!.stage, 'move');
const a = selected(view(g, 'ec', level));
assert.equal(a.type, 'decision');
assert.equal(a.event, g.pendingAmbassador!.event);
assert.equal(a.accept, undefined);
assert.equal(a.decline, undefined);
const done = applyAction(JSON.parse(JSON.stringify(g)) as Game, 'ec', a);
assert.equal(done.pendingAmbassador, null);
assert.deepEqual(g, before);
assert.equal(done.players[0].moved, 1);
assert.equal(done.players[0].shipped, true);
assert.deepEqual(done.hajr, ['ec']);
assert.equal(done.active, 'in');
assert.deepEqual(done.movementRemaining, g.movementRemaining);
assert.equal(done.players[1].reserves, g.players[1].reserves);
assert.equal(done.players[1].forces['arrakeen:10'], 1);
assert.equal(
Object.values(done.players[0].forces).reduce(
(n, amount) => n + amount,
0,
),
7,
);
assert.throws(() => applyAction(done, 'ec', a));
});
void test(`${level} the ally chooses its own typed force allocation and Ecaz gets no relocation descriptor`, () => {
const initial = fixture();
army(initial, 'al', { 'wind_pass:14': 4, 'wind_pass:15': 3 });
initial.players[2].elites = {
reserves: 0,
tanks: 0,
revived: 0,
forces: { 'wind_pass:14': 4, 'wind_pass:15': 3 },
};
const g = entered(initial, 'al');
for (const id of ['ec', 'in']) {
assert.equal(viewGame(g, id).ambassadorEntry!.movement, null);
assert.deepEqual(botActions(view(g, id, level)), []);
}
const a = selected(view(g, 'al', level));
assert.equal(
Object.values(a.forces as Record<string, number>).reduce(
(n, amount) => n + amount,
0,
),
Object.values(a.eliteForces as Record<string, number>).reduce(
(n, amount) => n + amount,
0,
),
);
const done = applyAction(g, 'al', a);
assert.equal(done.pendingAmbassador, null);
assert.equal(
Object.values(done.players[2].elites!.forces).reduce(
(n, amount) => n + amount,
0,
),
7,
);
assert.equal(done.players[2].moved, g.players[2].moved);
});
void test(`${level} marker-only movement uses separate private custody and preserves zero-valued No-Field`, () => {
const initial = fixture('richese');
const p = initial.players[2];
const state = createRicheseNoField(['zero', 'three', 'five']);
p.noField = deployRicheseNoField(state, {
tokenId: 'zero',
controller: 'al',
location: { territory: 'wind_pass', sector: 14 },
});
p.noFieldEvent = 'private-marker-event';
const g = entered(initial, 'al'),
a = selected(view(g, 'al', level));
assert.deepEqual(a.forces, {});
assert.deepEqual(a.noField, {
tokenId: 'zero',
event: 'private-marker-event',
});
assert.notEqual(a.event, 'private-marker-event');
const done = applyAction(g, 'al', a);
assert.equal(done.pendingAmbassador, null);
assert.deepEqual(done.players[2].forces, {});
assert.equal(done.players[2].reserves, 20);
assert.deepEqual(done.players[2].noField!.tokens, p.noField.tokens);
assert.ok('territory' in done.players[2].noField!.deployed!.location);
assert.equal(
done.players[2].noField!.deployed!.location.territory,
a.territory,
);
});
}
void test('all profiles filter blocked destinations before selection and omit stationary destination-sector forces', () => {
const initial = fixture();
army(initial, 'ec', { 'wind_pass:14': 4, 'wind_pass:15': 3 });
const g = entered(initial, 'ec');
for (const level of DIFFICULTIES) {
const v = view(g, 'ec', level);
const source = v.ambassadorEntry!.movement!.sources[0];
source.destinations = source.destinations.map((d) => ({
...d,
blocked:
d.territory === 'wind_pass' && d.sector === 15
? null
: 'Blocked test projection',
}));
const a = selected(v);
assert.equal(a.territory, 'wind_pass');
assert.equal(a.sector, 15);
assert.equal(
(a.forces as Record<string, number>)['wind_pass:15'],
undefined,
);
assert.doesNotThrow(() => applyAction(g, 'ec', a));
source.destinations.forEach((d) => {
d.blocked = 'Blocked test projection';
});
assert.deepEqual(
botActions(v),
[],
'no invented decline or rejected destination when none is supported',
);
}
});
void test('bots preserve one source stronghold occupant when moving to another territory', () => {
const initial = fixture();
army(initial, 'ec', { 'carthag:11': 5 });
const g = entered(initial, 'ec');
for (const level of DIFFICULTIES) {
const a = selected(view(g, 'ec', level));
assert.equal((a.forces as Record<string, number>)['carthag:11'], 4);
const done = applyAction(g, 'ec', a);
assert.equal(done.players[0].forces['carthag:11'], 1);
}
});
void test('private opponent state and deck order do not change a relocation candidate', () => {
const initial = fixture();
army(initial, 'ec', { 'wind_pass:14': 4 });
const g = entered(initial, 'ec'),
changed = structuredClone(g);
changed.players[2].hand.push(changed.deck.shift()!);
changed.players[2].spice = 0;
changed.players[2].traitors = [changed.players[1].leaders[0].id];
changed.deck.reverse();
for (const level of DIFFICULTIES)
assert.deepEqual(
selected(view(g, 'ec', level)),
selected(view(changed, 'ec', level)),
);
});
void test('BG copy explicitly considers Fremen and skips it without a group when another effect is available', () => {
const initial = fixture('ixians', true);
army(initial, 'ec', { 'wind_pass:14': 2 });
let g = enter(initial);
g = applyAction(g, 'ec', {
type: 'decision',
event: g.pendingAmbassador!.event,
trigger: true,
beneficiary: 'ec',
});
assert.equal(g.pendingAmbassador!.stage, 'copy');
for (const level of DIFFICULTIES) {
const v = view(g, 'ec', level);
v.ambassadorEntry!.copies = v.ambassadorEntry!.copies.filter(
(c) => c.effect === 'fremen',
);
const a = selected(v);
assert.equal(a.effect, 'fremen');
const moving = applyAction(g, 'ec', a);
const done = applyAction(moving, 'ec', selected(view(moving, 'ec', level)));
assert.equal(done.pendingAmbassador, null);
assert.equal(
done.ecazAmbassadors!.tokens.find((t) => t.effect === 'beneGesserit')!
.zone,
'removed',
);
const empty = structuredClone(g);
army(empty, 'ec', {});
assert.notEqual(selected(view(empty, 'ec', level)).effect, 'fremen');
}
});