Files
Dune/tests/nexus-inspection-bots.test.ts

328 lines
9.1 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
viewGame,
type GameView,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { cashInCards } from '../game/choam-karama';
import {
createNexusInspection,
allowNexusInspection,
answerNexusInspection,
reopenNexusInspection,
} from '../game/battle-inspections';
/** These are consumer tests of authoritative projected fields, not evidence
* of Nexus acquisition or effect execution. The underlying ordinary battle
* supplies real public map/leader/card shapes and conserved physical cards. */
function fixture(): GameView {
let g = createGame('NEXUSBOTS', newPlayer('e', 'Emperor', 'emperor'));
g.players.push(
newPlayer('q', 'Guild', 'guild'),
newPlayer('h', 'Harkonnen', 'harkonnen'),
);
Object.assign(g, {
status: 'playing',
phase: 6,
turn: 2,
storm: 18,
active: 'e',
order: ['e', 'q', 'h'],
deck: baseDeck(),
});
for (const p of g.players)
Object.assign(p, { forces: {}, reserves: 20, spice: 20, hand: [] });
for (const p of g.players.slice(0, 2)) {
p.forces = { 'arrakeen:10': 5 };
p.reserves = 15;
}
for (const kind of ['projectile', 'shield', 'poison', 'snooper'] as const) {
const index = g.deck.findIndex((c) => c.kind === kind);
g.players[0].hand.push(g.deck.splice(index, 1)[0]);
}
g = applyAction(g, 'e', {
type: 'chooseBattle',
territory: 'arrakeen',
target: 'q',
});
const v = viewGame(g, 'e');
v.battle!.ownCommitments = [];
v.battle!.nexusInsights = [];
v.battle!.nexusInspection = null;
v.nexusAtreides = null;
return v;
}
const own = (v: GameView) => v.players.find((p) => p.id === v.me)!;
void test('all four profiles preserve simultaneous native and Nexus commitments including zero and null', () => {
for (const difficulty of DIFFICULTIES)
for (const fields of ['cards', 'dial', 'leader'] as const) {
const v = fixture(),
me = own(v),
b = v.battle!;
me.bot = difficulty;
const weapon = me.hand!.find((c) => c.kind === 'projectile')!.id;
b.ownCommitments =
fields === 'cards'
? [
{
source: 'native',
beneficiary: 'q',
target: 'e',
field: 'weapon',
value: weapon,
},
{
source: 'nexus',
beneficiary: 'q',
target: 'e',
field: 'defense',
value: null,
},
]
: fields === 'dial'
? [
{
source: 'native',
beneficiary: 'q',
target: 'e',
field: 'weapon',
value: weapon,
},
{
source: 'nexus',
beneficiary: 'q',
target: 'e',
field: 'dial',
value: 0,
},
]
: [
{
source: 'native',
beneficiary: 'q',
target: 'e',
field: 'leader',
value: me.leaders[1].id,
},
{
source: 'nexus',
beneficiary: 'q',
target: 'e',
field: 'dial',
value: 2,
},
];
const before = structuredClone(v),
actions = botActions(v);
assert.ok(actions.length);
for (const action of actions) {
assert.equal(action.type, 'battlePlan');
for (const element of b.ownCommitments)
assert.equal(action[element.field], element.value);
}
assert.deepEqual(v, before);
}
});
void test('pending Nexus answers bind the battle event and preserve the earlier native weapon', () => {
for (const difficulty of DIFFICULTIES) {
const v = fixture(),
me = own(v),
b = v.battle!;
me.bot = difficulty;
const weapon = me.hand!.find((c) => c.kind === 'projectile')!.id;
b.ownCommitments = [
{
source: 'native',
beneficiary: 'q',
target: 'e',
field: 'weapon',
value: weapon,
},
];
b.preparation = {
kind: 'nexusPrescienceAnswer',
owner: 'e',
beneficiary: 'q',
};
b.nexusInspection = {
event: b.event!,
mode: 'cunning',
owner: 'q',
target: 'e',
field: 'defense',
stage: 'answer',
};
const actions = botActions(v);
assert.ok(actions.length);
for (const action of actions) {
assert.equal(action.type, 'nexusPrescienceAnswer');
assert.equal(action.event, b.event);
assert.ok(
action.value === null ||
me.hand!.some(
(c) =>
c.id === action.value && ['shield', 'snooper'].includes(c.kind),
),
);
}
b.nexusInspection.stage = 'response';
assert.deepEqual(botActions(v), []);
b.nexusInspection.stage = 'answer';
b.nexusInspection.event = 'old-battle';
assert.deepEqual(botActions(v), []);
}
});
void test('all profiles choose only an offered distinct Nexus field and respect public interruption fences', () => {
for (const difficulty of DIFFICULTIES) {
const v = fixture();
own(v).bot = difficulty;
v.nexusCards = {
card: 'atreides',
deckCount: 11,
discardCount: 0,
held: { e: true, q: false, h: false },
turn: 2,
choices: [],
waiting: [],
};
v.nexusAtreides = {
event: v.battle!.event!,
mode: 'cunning',
fields: ['defense', 'leader', 'dial'],
blocked: null,
};
assert.deepEqual(botActions(v), [
{
type: 'nexusAtreides',
event: v.battle!.event,
mode: 'cunning',
field: 'defense',
},
]);
v.nexusAtreides.mode = 'secretAlly';
v.nexusAtreides.fields = ['weapon', 'dial'];
assert.equal(
botActions(v)[0].field,
['Easy', 'Medium'].includes(difficulty) ? 'dial' : 'weapon',
);
for (const edit of [
(x: GameView) => {
x.nexusAtreides!.blocked = 'Awaiting timing ruling';
},
(x: GameView) => {
x.nexusAtreides!.event = 'old-battle';
},
(x: GameView) => {
x.nexusCards!.card = 'ecaz';
},
(x: GameView) => {
x.automaticContinuationPending = true;
},
(x: GameView) => {
x.phaseOpening = { passed: [] };
},
(x: GameView) => {
x.response = { kind: 'voice', owner: 'q', passed: [] };
},
]) {
const blocked = structuredClone(v);
edit(blocked);
assert.ok(botActions(blocked).every((a) => a.type !== 'nexusAtreides'));
}
v.nexusCards.waiting = ['q'];
assert.deepEqual(botActions(v), []);
}
});
void test('private enemy fields are never consulted and retired Nexus observations are not current predictions', () => {
const v = fixture();
own(v).bot = 'Hard';
const base = botActions(v);
v.battle!.nexusInspection = {
event: v.battle!.event!,
mode: 'secretAlly',
owner: 'e',
target: 'q',
field: 'weapon',
stage: 'answer',
};
v.battle!.nexusInsights = [
{
field: 'weapon',
value: 'treachery-3',
label: 'Historical only',
active: false,
},
];
for (const p of v.players.filter((p) => p.id !== v.me))
for (const field of ['hand', 'spice', 'traitors'])
Object.defineProperty(p, field, {
get() {
throw Error(`Read private ${field}`);
},
configurable: true,
});
assert.deepEqual(botActions(v), base);
});
void test('CHOAM cash-in reserves both answered sources but releases a retired Nexus binding without losing its history', () => {
const state = createGame('NEXUSCASH', newPlayer('a', 'Atreides', 'atreides'));
state.players.push(newPlayer('c', 'CHOAM', 'choam'));
const cards = baseDeck()
.filter((c) => c.kind === 'worthless')
.slice(0, 3),
p = state.players[1];
p.hand = cards;
const native = { player: 'a', field: 'weapon' as const, value: cards[0].id };
const context = {
event: 'battle-cash',
attacker: 'a',
defender: 'c',
players: state.players,
native,
};
const inspection = answerNexusInspection(
context,
allowNexusInspection(
context,
createNexusInspection(context, {
mode: 'cunning',
owner: 'a',
target: 'c',
field: 'defense',
}),
),
cards[1].id,
);
state.battle = {
event: context.event,
territory: 'arrakeen',
attacker: 'a',
defender: 'c',
plans: {},
revealed: false,
traitorCalls: {},
prescience: native,
nexusInspection: inspection,
};
assert.deepEqual(
cashInCards(state, p).map((c) => c.id),
[cards[2].id],
);
state.battle.nexusInspection = reopenNexusInspection(context, inspection);
assert.deepEqual(
cashInCards(state, p).map((c) => c.id),
[cards[1].id, cards[2].id],
);
assert.deepEqual(state.battle.nexusInspection.answers, [cards[1].id]);
});