Files
Dune/tests/tupile-intelligence-engine.test.ts

181 lines
8.4 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import {
holdTupileCard, positionTupileContact, tupileIntelligenceFixture,
tupileInventory, tupilePlayer, tupileReject, tupileReload,
} from './fixture-tupile-intelligence';
const request = (category = 'weapons', target = 'a') => ({ type: 'tupileIntelligence', target, category });
function heldAnswer(g: Game): void {
for (const kind of ['projectile', 'shield', 'worthless', 'ghola']) holdTupileCard(g, 'a', kind);
tupilePlayer(g, 'a').spice = 13;
}
void test('actual Tupile contact grants one private snapshot off turn, without a response or material cost', () => {
for (const advanced of [false, true]) {
const g = tupileIntelligenceFixture({ advanced });
heldAnswer(g);
g.active = 'h';
const before = tupileReload(g);
const next = applyAction(g, 'c', request());
assert.deepEqual(g, before);
const receipt = viewGame(next, 'c').tupileIntelligence!.receipts[0];
assert.deepEqual({ ...receipt, event: 'event' }, {
event: 'event', target: 'a', faction: 'atreides', category: 'weapons', spice: 13, count: 1, turn: 2, phase: 5,
});
assert.deepEqual(next.players, before.players);
assert.deepEqual(next.homeworlds, before.homeworlds);
assert.equal(next.active, 'h');
assert.equal(next.response, null);
assert.equal(next.decision, null);
assert.equal(next.tupileIntelligence!.receipts.length, 1);
assert.deepEqual(next.homeworldOccupationHistory!.qualifications, []);
tupileInventory(next);
}
});
void test('all seat views keep the unchosen count and private receipt out of opponents and public logs', () => {
const g = tupileIntelligenceFixture();
heldAnswer(g);
const next = applyAction(g, 'c', request('defenses'));
const receipt = next.tupileIntelligence!.receipts[0];
for (const id of ['c', 'a', 'h']) {
const view = viewGame(next, id);
assert.deepEqual(Object.keys(view.tupileIntelligence?.receipts[0] ?? {}).sort(), id === 'c'
? ['category', 'count', 'event', 'faction', 'phase', 'spice', 'target', 'turn'] : []);
if (id !== 'c') {
assert.equal(view.tupileIntelligence, null);
assert.equal(JSON.stringify(view).includes(receipt.event), false);
assert.equal(JSON.stringify(view).includes(receipt.signature), false);
}
for (const other of view.players.filter((p) => p.id !== id)) assert.equal(other.hand, undefined);
}
const publicLog = JSON.stringify(next.log.slice(g.log.length));
assert.match(publicLog, /Tupile intelligence/);
assert.doesNotMatch(publicLog, /13|defenses|weapons|"count"|"spice"/);
});
void test('a foreign arrival on garrisoned Tupile grants contact independently for each opposing faction', () => {
let g = tupileIntelligenceFixture({ contact: false });
for (const [id, faction] of [['a', 'atreides'], ['h', 'harkonnen']]) {
g.active = id;
g = applyAction(g, id, {
type: 'homeworldShip', event: viewGame(g, id).homeworldShipment!.event,
destination: 'homeworld:choam', sources: { [`homeworld:${faction}`]: { normal: 1, elite: 0 } },
});
assert.deepEqual(g.homeworldOccupationHistory!.qualifications, []);
const target = viewGame(g, 'c').tupileIntelligence!.targets.find((seat) => seat.player === id)!;
assert.deepEqual(target.contact, ['homeworld:choam']);
assert.equal(target.blocked, null);
g = applyAction(g, 'c', request('defenses', id));
}
assert.deepEqual(g.tupileIntelligence!.receipts.map((receipt) => receipt.faction), ['atreides', 'harkonnen']);
assert.equal(tupilePlayer(g, 'c').reserves, 10);
tupileInventory(g);
});
void test('the lifetime faction use survives category changes, later phases, departure and reentry', () => {
let g = applyAction(tupileIntelligenceFixture(), 'c', request());
for (const category of ['weapons', 'defenses']) tupileReject(g, 'c', request(category), /already been used/);
positionTupileContact(g, false);
g.turn = 3;
g.phase = 2;
positionTupileContact(g, true);
g = tupileReload(g);
assert.equal(viewGame(g, 'c').tupileIntelligence!.receipts.length, 1);
tupileReject(g, 'c', request('defenses'), /already been used/);
assert.deepEqual(normalizeAutomaticGame(g).tupileIntelligence, g.tupileIntelligence);
tupileInventory(g);
});
void test('JSON reload retains the historical answer when the target spends spice and exchanges held cards', () => {
let g = tupileIntelligenceFixture();
heldAnswer(g);
g = applyAction(g, 'c', request());
const answer = structuredClone(viewGame(g, 'c').tupileIntelligence!.receipts);
const target = tupilePlayer(g, 'a');
target.spice = 0;
g.deck.push(...target.hand.splice(0));
holdTupileCard(g, 'a', 'shield');
positionTupileContact(g, false);
g = normalizeAutomaticGame(tupileReload(g));
assert.deepEqual(viewGame(g, 'c').tupileIntelligence!.receipts, answer);
assert.equal(target.hand.length, 1);
tupileInventory(g);
});
void test('current population, contact, pending timing and malformed requests reject without changing the game', () => {
const g = tupileIntelligenceFixture();
const high = tupileReload(g);
tupilePlayer(high, 'c').reserves += 2;
tupilePlayer(high, 'c').forces['polar_sink:0'] -= 2;
tupileReject(high, 'c', request(), /at most ten/);
const absent = tupileReload(g);
positionTupileContact(absent, false);
tupileReject(absent, 'c', request(), /must be on/);
const opening = tupileReload(g);
opening.phaseOpening = { passed: [], initialize: false };
assert.match(viewGame(opening, 'c').tupileIntelligence!.blocked!, /Finish/);
tupileReject(opening, 'c', request(), /Finish/);
tupileReject(g, 'a', request(), /Only CHOAM/);
for (const action of [request('both'), request('weapons', 'c'), request('weapons', 'missing'), { ...request(), count: 0 }, { ...request(), spice: 0 }])
tupileReject(g, 'c', action);
assert.deepEqual(g.tupileIntelligence!.receipts, []);
});
void test('deleted receipts or mismatched initialized history reject projection, normalization and actions immutably', () => {
const original = applyAction(tupileIntelligenceFixture(), 'c', request());
const corruptions: ((g: Game) => void)[] = [
(g) => { g.tupileIntelligence!.receipts = []; },
(g) => { g.tupileIntelligence!.receipts[0].count++; },
(g) => { g.tupileIntelligence!.receipts[0].category = 'defenses'; },
(g) => { delete g.tupileIntelligence; },
(g) => { delete g.homeworldOccupationHistory; },
(g) => { delete g.homeworlds!.historyVersion; },
];
for (const corrupt of corruptions) {
const broken = tupileReload(original);
corrupt(broken);
const before = tupileReload(broken);
for (const id of ['c', 'a', 'h']) assert.throws(() => viewGame(broken, id), /ledger|history|observation/i);
assert.throws(() => normalizeAutomaticGame(broken), /ledger|history|observation/i);
tupileReject(broken, 'c', request('defenses'), /ledger|history|observation/i);
assert.deepEqual(broken, before);
}
});
void test('legacy saves with no initialized history remain readable but cannot invent a fresh intelligence allowance', () => {
const g = tupileIntelligenceFixture();
delete g.homeworlds!.historyVersion;
delete g.homeworldOccupationHistory;
delete g.tupileIntelligence;
const before = tupileReload(g);
for (const id of ['c', 'a', 'h']) {
const view = viewGame(g, id);
if (id === 'c') {
assert.match(view.tupileIntelligence!.blocked!, /lacks the original/);
assert.deepEqual(view.tupileIntelligence!.receipts, []);
} else assert.equal(view.tupileIntelligence, null);
}
tupileReject(g, 'c', request(), /lacks the original/);
const normalized = normalizeAutomaticGame(g);
assert.equal(normalized.tupileIntelligence, undefined);
assert.equal(normalized.homeworldOccupationHistory, undefined);
assert.deepEqual(g, before);
});
void test('every real bot profile emits a legal owned request and stops after using that faction', () => {
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const g = tupileIntelligenceFixture();
g.active = 'a';
tupilePlayer(g, 'c').bot = profile;
const actions = botActions(viewGame(g, 'c'));
assert.deepEqual(actions, [request()]);
const next = applyAction(g, 'c', actions[0]);
assert.equal(next.tupileIntelligence!.receipts.length, 1);
assert.equal(botActions(viewGame(next, 'c')).some((action) => action.type === 'tupileIntelligence'), false);
}
});