Files
Dune/tests/guild-ambassador-bots.test.ts
Quintine 767bace774 Separate Spiritual Advisor shipment matching and alone conversion
Apply the Advanced retained-fighter target cutover through shared quotes and controls. Preserve committed foreign shipments while handling free-packet shipping, Polar matching, actual placement and original arrival once.
2026-10-24 22:28:42 +11:00

369 lines
12 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 type { FactionId } from '../game/catalog';
function fixture(faction: FactionId = 'ixians', copy = false) {
const g = createGame('GAMBOTS', 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' : 'guild';
const token = inventory.tokens.find((t) => t.effect === effect)!;
inventory.cohort = [
token.id,
...inventory.tokens
.filter(
(t) => t.effect !== 'ecaz' && t.effect !== 'guild' && 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: 'guild',
});
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} uses a real Guild Ambassador reserve shipment without spending ordinary allowances`, () => {
const initial = fixture();
initial.players[0].shipped = true;
initial.players[0].moved = 1;
initial.hajr = ['ec'];
const g = entered(initial, 'ec');
assert.equal(g.pendingAmbassador!.stage, 'ship');
const a = selected(view(g, 'ec', level));
assert.equal(a.amount, 4);
assert.equal(a.elite, 0);
assert.equal(a.noField, undefined);
assert.equal(a.event, g.pendingAmbassador!.event);
const before = structuredClone(g);
const done = applyAction(JSON.parse(JSON.stringify(g)) as Game, 'ec', a);
assert.deepEqual(g, before);
assert.equal(done.pendingAmbassador, null);
assert.equal(done.players[0].reserves, 16);
assert.equal(done.players[0].spice, g.players[0].spice);
assert.equal(done.players[0].shipped, true);
assert.equal(done.players[0].moved, 1);
assert.deepEqual(done.hajr, ['ec']);
assert.equal(done.active, 'in');
assert.deepEqual(done.movementRemaining, g.movementRemaining);
assert.equal(done.players[1].forces['arrakeen:10'], 1);
assert.equal(done.players[1].reserves, g.players[1].reserves);
assert.throws(() => applyAction(done, 'ec', a));
});
void test(`${level} allied elite-only reserves require canonical elite allocation and a private descriptor`, () => {
const initial = fixture();
army(initial, 'al', { 'wind_pass:14': 17 });
initial.players[2].elites = {
reserves: 3,
tanks: 0,
revived: 0,
forces: {},
};
const g = entered(initial, 'al');
for (const id of ['ec', 'in']) {
assert.equal(viewGame(g, id).ambassadorEntry!.shipment, null);
assert.deepEqual(botActions(view(g, id, level)), []);
}
const a = selected(view(g, 'al', level));
assert.equal(a.amount, 3);
assert.equal(a.elite, 3);
const done = applyAction(g, 'al', a);
assert.equal(done.pendingAmbassador, null);
assert.equal(done.players[2].reserves, 0);
assert.equal(done.players[2].elites!.reserves, 0);
assert.equal(
Object.values(done.players[2].elites!.forces).reduce((n, x) => n + x, 0),
3,
);
assert.equal(done.players[2].spice, g.players[2].spice);
});
void test(`${level} filters every blocked destination before scoring and explicitly sends zero if none remain`, () => {
const g = entered(fixture(), 'ec');
const v = view(g, 'ec', level);
const descriptor = v.ambassadorEntry!.shipment!;
const legal = descriptor.destinations.find(
(d) => d.territory === 'polar_sink',
)!;
assert.ok(legal);
descriptor.destinations.forEach((d) => {
d.blocked = d === legal ? null : 'Unresolved competing arrival';
});
const a = selected(v);
assert.equal(a.territory, 'polar_sink');
assert.doesNotThrow(() => applyAction(g, 'ec', a));
descriptor.destinations.forEach((d) => {
d.blocked = 'Unresolved competing arrival';
});
const zero = selected(v);
assert.deepEqual(zero, {
type: 'decision',
event: g.pendingAmbassador!.event,
amount: 0,
});
const done = applyAction(g, 'ec', zero);
assert.equal(done.pendingAmbassador, null);
assert.deepEqual(done.players[0].forces, g.players[0].forces);
assert.equal(done.players[0].reserves, g.players[0].reserves);
assert.equal(done.players[0].shipped, g.players[0].shipped);
assert.equal(done.active, 'in');
});
}
void test('hidden opponent balances, cards, traitors and deck order do not change a Guild shipment candidate', () => {
const g = entered(fixture(), '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('mixed reserves enforce the elite minimum for Easy and the available maximum for other levels', () => {
const initial = fixture();
army(initial, 'al', { 'wind_pass:14': 15 });
initial.players[2].elites = {
reserves: 2,
tanks: 0,
revived: 0,
forces: {},
};
const g = entered(initial, 'al');
for (const level of DIFFICULTIES) {
const a = selected(view(g, 'al', level));
assert.equal(a.amount, 4);
assert.equal(a.elite, level === 'Easy' ? 1 : 2);
const done = applyAction(g, 'al', a);
assert.equal(done.players[2].reserves, 1);
assert.equal(done.players[2].elites!.reserves, 2 - Number(a.elite));
}
});
void test('BG copy explicitly selects Guild and completes its real shipment at every level', () => {
let g = enter(fixture('ixians', true));
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 === 'guild',
);
const copy = selected(v);
assert.equal(copy.effect, 'guild');
const shipping = applyAction(g, 'ec', copy);
const done = applyAction(
shipping,
'ec',
selected(view(shipping, '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', { 'wind_pass:14': 20 });
assert.notEqual(selected(view(empty, 'ec', level)).effect, 'guild');
}
});
function advisorWindow() {
const initial = fixture('beneGesserit');
initial.storm = 14;
army(initial, 'in', { 'imperial_basin:10': 1 });
// A real move avoids the entrant's independent off-planet accompaniment.
let g = applyAction(initial, 'in', {
type: 'move',
forces: { 'imperial_basin:10': 1 },
territory: 'arrakeen',
sector: 10,
});
g = applyAction(g, 'ec', {
type: 'decision',
event: g.pendingAmbassador!.event,
trigger: true,
beneficiary: 'ec',
});
g = applyAction(g, 'ec', {
type: 'decision',
event: g.pendingAmbassador!.event,
amount: 1,
elite: 0,
territory: 'wind_pass',
sector: 15,
});
assert.equal(g.decision?.kind, 'advisor');
assert.equal(g.decision.player, 'al');
return g;
}
void test('Guild Ambassador accompaniment candidates exclude the storm sector at every level', () => {
const g = advisorWindow();
assert.equal(viewGame(g, 'ec').guildAmbassadorAdvisorChoices, null);
assert.equal(viewGame(g, 'in').guildAmbassadorAdvisorChoices, null);
for (const level of DIFFICULTIES) {
const v = view(g, 'al', level);
const before = structuredClone(v);
const descriptor = v.spiritualAdvisorChoices ?? v.guildAmbassadorAdvisorChoices!;
assert.ok(
descriptor.choices.some(
(c) => c.accompany && c.sector === 14 && c.blocked,
),
);
assert.ok(
descriptor.choices.some(
(c) => c.accompany && c.sector === 15 && !c.blocked,
),
);
const actions = botActions(v);
assert.deepEqual(v, before);
assert.ok(actions.length > 1);
for (const a of actions) {
if (a.accept) {
assert.ok(
descriptor.choices.some(
(c) =>
!c.blocked &&
c.accompany === a.accompany &&
c.territory === a.territory &&
c.sector === a.sector,
),
);
assert.notEqual(a.sector, 14);
}
assert.doesNotThrow(() => applyAction(g, 'al', a));
}
const done = applyAction(g, 'al', actions[0]);
assert.equal(done.pendingAmbassador, null);
assert.equal(done.players[2].reserves, 19);
assert.equal(done.players[1].moved, g.players[1].moved);
assert.equal(done.active, 'in');
}
});
void test('projected blocked accompaniment falls back to Polar Sink, then decline when every choice is blocked', () => {
const g = advisorWindow();
for (const level of DIFFICULTIES) {
const v = view(g, 'al', level);
const choices = (v.spiritualAdvisorChoices ?? v.guildAmbassadorAdvisorChoices!).choices;
// The policy consumes projected blocking regardless of its public cause.
// Server-side competing-arrival rules have separate engine fixtures.
for (const c of choices)
if (c.accompany) c.blocked = 'Competing arrival is unavailable';
const actions = botActions(v);
assert.equal(actions[0].accept, true);
assert.equal(actions[0].accompany, false);
assert.equal(actions[0].territory, 'polar_sink');
assert.equal(actions[0].sector, 0);
const done = applyAction(g, 'al', actions[0]);
assert.equal(done.players[2].forces['polar_sink:0'], 1);
assert.equal(done.pendingAmbassador, null);
for (const c of choices) c.blocked = 'No available reserve shipment';
assert.deepEqual(botActions(v), [{ type: 'decision', accept: false }]);
assert.doesNotThrow(() => applyAction(g, 'al', botActions(v)[0]));
}
});