Files
Dune/tests/truth-movement-advanced-engine.test.ts

215 lines
12 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { TERRITORIES, gameDistance, splitLocation } from '../game/board';
import { viewGame, type Game } from '../game/engine';
import { createTruthMovementAdvancedFixture, askTruthMovementAdvanced as askAdvancedMovement } from './fixture-truth-movement-advanced';
import { actTruthMovement as act, advanceTruthMovement as advance,
truthMovementClean as clean, truthMovementPlayer as player,
truthMovementInventory as inventory } from './fixture-truth-movement';
import { createAdvisorMatchingMoveFixture } from './fixture-advisor-matching-move';
void test('genuine Advanced Yes binds physical movement and preserves immediate Hajr timing', () => {
const f = createTruthMovementAdvancedFixture();
assert.throws(() => act(f.game, f.target, { type: 'card', card: f.hajr! }),
'Advanced Hajr cannot prepare the initial move');
let g = askAdvancedMovement(f.game, f.asker, f.truthtrance, f.target);
assert.deepEqual(viewGame(g, f.target).truthMovementAnswers, ['yes', 'no']);
g = act(g, f.target, { type: 'truthAnswer', answer: 'yes' });
assert.throws(() => act(g, f.target, { type: 'endMovement' }));
g = act(g, f.target, { type: 'move', from: 'arrakeen:10', amount: 4,
territory: 'imperial_basin', sector: 9 });
g = advance(g, clean);
assert.ok(!g.movementPromises![0].fulfilled && !g.movementPromises![0].released);
const next = viewGame(g, f.target).movementPromisePolicy?.[0];
assert.deepEqual(next, { type: 'card', card: f.hajr });
g = advance(act(g, f.target, next!), clean);
g = act(g, f.target, { type: 'move', from: 'arrakeen:10', amount: 6, territory: 'carthag', sector: 11 });
assert.equal(g.movementPromises![0].fulfilled, true);
g = act(advance(g, clean), f.target, { type: 'endMovement' });
assert.equal(player(g, f.target).forces['carthag:11'], 6);
assert.deepEqual(inventory(g), f.originalCards);
});
void test('Advanced No remains binding through a genuine later Hajr movement', () => {
const f = createTruthMovementAdvancedFixture();
let g = askAdvancedMovement(f.game, f.asker, f.truthtrance, f.target);
g = act(g, f.target, { type: 'truthAnswer', answer: 'no' });
g = advance(act(g, f.target, { type: 'move', from: 'arrakeen:10', amount: 4,
territory: 'imperial_basin', sector: 9 }), clean);
g = advance(act(g, f.target, { type: 'card', card: f.hajr! }), clean);
const before = structuredClone(g);
assert.throws(() => act(g, f.target, { type: 'move', from: 'arrakeen:10', amount: 6,
territory: 'carthag', sector: 11 }));
assert.deepEqual(g, before);
g = act(g, f.target, { type: 'endMovement' });
assert.equal(g.movementPromises![0].fulfilled, true);
assert.deepEqual(inventory(g), f.originalCards);
});
void test('an Advanced unused shipment prepares a larger ground group without itself fulfilling Yes', () => {
const f = createTruthMovementAdvancedFixture();
let g = askAdvancedMovement(f.beforeShipment, f.asker, f.truthtrance, f.target, 11);
assert.ok(viewGame(g, f.target).truthMovementAnswers!.includes('yes'));
g = act(g, f.target, { type: 'truthAnswer', answer: 'yes' });
const shipment = viewGame(g, f.target).movementPromisePolicy?.[0];
assert.equal(shipment?.type, 'ship');
g = advance(act(g, f.target, shipment!), clean);
assert.ok(!g.movementPromises![0].fulfilled);
const movement = viewGame(g, f.target).movementPromisePolicy?.[0];
assert.equal(movement?.type, 'move');
g = act(g, f.target, movement!);
assert.equal(g.movementPromises![0].fulfilled, true);
});
function fremenRoute(g: Game, actor: string) {
const owner = player(g, actor);
const from = Object.keys(owner.forces).find(key => owner.forces[key] >= 3 && (owner.elites?.forces[key] ?? 0) >= 2);
assert.ok(from);
const destination = TERRITORIES.filter(t => t.type === 'sand').flatMap(t =>
t.sectors.filter(sector => sector !== g.storm).map(sector => ({ territory: t.id, sector })))
.find(to => gameDistance(g, from, `${to.territory}:${to.sector}`,
point => splitLocation(point).sector !== 0 && splitLocation(point).sector === g.storm) === 2);
assert.ok(destination);
return { type: 'move', forces: { [from]: 3 }, eliteForces: { [from]: 2 }, ...destination };
}
for (const cancel of [false, true]) void test(`native Advanced typed Fremen movement ${cancel ? 'prevention releases an impossible Yes' : 'waits for allowance before fulfillment'}`, () => {
const f = createTruthMovementAdvancedFixture({ targetFaction: 'fremen', hajr: false });
const move = fremenRoute(f.game, f.target);
let g = askAdvancedMovement(f.game, f.asker, f.truthtrance, f.target, 3, move.territory);
g = act(g, f.target, { type: 'truthAnswer', answer: 'yes' });
const before = structuredClone(player(g, f.target));
g = act(g, f.target, move);
assert.equal(g.response?.kind, 'fremenMovement');
assert.equal(g.movementPromises![0].fulfilled, undefined);
assert.equal(g.movementPromises![0].released, undefined);
assert.deepEqual(player(g, f.target).forces, before.forces);
if (cancel) {
assert.ok(f.karama);
g = act(g, f.asker, { type: 'card', card: f.karama, mode: 'cancel' });
assert.equal(g.movementPromises![0].released, true);
assert.deepEqual(player(g, f.target).forces, before.forces);
assert.equal(player(g, f.target).moved, 0);
} else {
g = advance(g, clean);
assert.equal(g.movementPromises![0].fulfilled, true);
assert.equal(player(g, f.target).elites!.forces[`${move.territory}:${move.sector}`], 2);
assert.equal(player(g, f.target).forces[`${move.territory}:${move.sector}`], 3);
}
assert.deepEqual(inventory(g), f.originalCards);
});
void test('elite combat strength cannot substitute for eleven physical forces in one movement', () => {
const f = createTruthMovementAdvancedFixture({ targetFaction: 'fremen', hajr: false });
const move = fremenRoute(f.game, f.target);
const g = askAdvancedMovement(f.game, f.asker, f.truthtrance, f.target, 11, move.territory);
assert.deepEqual(viewGame(g, f.target).truthMovementAnswers, ['no']);
});
for (const cancel of [false, true]) void test(`controlled original Truthtrance plus native BG matching ${cancel ? 'prevention retains a legal replacement' : 'allowance fulfills only at transfer'}`, () => {
const f = createAdvisorMatchingMoveFixture();
// Only this card acquisition is controlled; setup, original advisors/fighter,
// paid shipment, Karama and matching declaration come from the native source.
const source = structuredClone(f.game), at = source.deck.findIndex(c => c.effect === 'truthtrance');
assert.ok(at >= 0); const truth = source.deck.splice(at, 1)[0];
player(source, f.emperor).hand.push(truth);
let g = askAdvancedMovement(source, f.emperor, truth.id, f.bg, 1, f.destination);
g = act(g, f.bg, { type: 'truthAnswer', answer: 'yes' });
const forces = structuredClone(player(g, f.bg).forces);
g = act(g, f.bg, f.action);
assert.ok(g.response?.advisorMatchingSource);
assert.deepEqual(player(g, f.bg).forces, forces);
assert.ok(!g.movementPromises![0].fulfilled && !g.movementPromises![0].released);
if (cancel) {
g = act(g, f.holder, { type: 'card', card: f.karama, mode: 'cancel' });
assert.ok(!g.movementPromises![0].fulfilled && !g.movementPromises![0].released);
g = act(g, f.bg, f.ordinaryAction);
} else g = advance(g, clean);
assert.equal(g.movementPromises![0].fulfilled, true);
assert.deepEqual(inventory(g), f.originalInventory);
});
function controlledTruth(game: Game, recipient: string) {
const at = game.deck.findIndex(card => card.effect === 'truthtrance'); assert.ok(at >= 0);
const card = game.deck.splice(at, 1)[0]; player(game, recipient).hand.push(card); return card.id;
}
const routeDistance = (g: Game, from: string, to: string) => gameDistance(g, from, to,
point => splitLocation(point).sector !== 0 && splitLocation(point).sector === g.storm);
for (const cancel of [false, true]) void test(`native pre-first-shipment advisor preparation ${cancel ? 'prevention releases the blocked route' : 'prepares the promised city-range move'}`, () => {
const f = createAdvisorMatchingMoveFixture();
let g = act(f.beforeShipment, f.bg, { type: 'bribe', target: f.emperor,
amount: player(f.beforeShipment, f.bg).spice });
assert.equal(g.advisorPreparation?.firstShipment, null, 'A real bribe does not close preparation');
const truth = controlledTruth(g, f.emperor);
const to = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(sector => ({ territory: t.id, sector })))
.find(to => to.sector !== g.storm && routeDistance(g, 'arrakeen:10', `${to.territory}:${to.sector}`) === 3);
assert.ok(to);
g = askAdvancedMovement(g, f.emperor, truth, f.bg, 1, to.territory);
assert.ok(viewGame(g, f.bg).truthMovementAnswers!.includes('yes'));
g = act(g, f.bg, { type: 'truthAnswer', answer: 'yes' });
const preparation = viewGame(g, f.bg).movementPromisePolicy?.[0];
assert.deepEqual(preparation, { type: 'prepareAdvisors', territory: 'arrakeen' });
g = act(g, f.bg, preparation!);
assert.ok(g.response?.advisorPreparationSource);
assert.ok(!g.movementPromises![0].fulfilled && !g.movementPromises![0].released);
if (cancel) {
g = act(g, f.holder, { type: 'card', card: f.karama, mode: 'cancel' });
assert.equal(g.movementPromises![0].released, true);
} else {
g = advance(g, clean);
const movement = viewGame(g, f.bg).movementPromisePolicy?.[0]; assert.equal(movement?.type, 'move');
g = act(g, f.bg, movement!); assert.equal(g.movementPromises![0].fulfilled, true);
}
assert.deepEqual(inventory(g), f.originalInventory);
});
void test('controlled advisor arrival flip supplies city range for the subsequent original Hajr', () => {
const f = createAdvisorMatchingMoveFixture();
let g = structuredClone(f.game);
const bg = player(g, f.bg), emperor = player(g, f.emperor);
// Controlled stance, two real reserve counters and original card custody
// isolate this two-move search boundary; not an unstaged native source.
bg.advisors!.imperial_basin = {};
emperor.reserves -= 2; emperor.forces['carthag:11'] = 1; emperor.forces['imperial_basin:9'] = 1;
const at = g.deck.findIndex(card => card.effect === 'hajr'); assert.ok(at >= 0);
const hajr = g.deck.splice(at, 1)[0]; bg.hand.push(hajr);
const truth = controlledTruth(g, f.emperor);
const to = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(sector => ({ territory: t.id, sector })))
.find(to => to.sector !== g.storm && routeDistance(g, 'carthag:11', `${to.territory}:${to.sector}`) === 3 &&
routeDistance(g, 'imperial_basin:9', `${to.territory}:${to.sector}`) > 2 &&
routeDistance(g, 'arrakeen:10', `${to.territory}:${to.sector}`) > 1);
assert.ok(to);
g = askAdvancedMovement(g, f.emperor, truth, f.bg, 1, to.territory);
assert.ok(viewGame(g, f.bg).truthMovementAnswers!.includes('yes'));
g = act(g, f.bg, { type: 'truthAnswer', answer: 'yes' });
const first = viewGame(g, f.bg).movementPromisePolicy?.[0];
assert.equal(first?.type, 'move'); assert.equal(first.territory, 'carthag'); assert.equal(first.fighters, true);
g = act(g, f.bg, first);
assert.equal(g.response?.kind, 'advisorFlip');
assert.ok(!g.movementPromises![0].fulfilled && !g.movementPromises![0].released);
g = advance(g, clean);
assert.deepEqual(viewGame(g, f.bg).movementPromisePolicy?.[0], { type: 'card', card: hajr.id });
g = advance(act(g, f.bg, { type: 'card', card: hajr.id }), clean);
g = act(g, f.bg, viewGame(g, f.bg).movementPromisePolicy![0]);
assert.equal(g.movementPromises![0].fulfilled, true);
assert.deepEqual(inventory(g), f.originalInventory);
});
void test('all four existing policies answer and complete the genuine Advanced movement question legally', () => {
const f = createTruthMovementAdvancedFixture();
const question = askAdvancedMovement(f.game, f.asker, f.truthtrance, f.target);
for (const difficulty of DIFFICULTIES) {
let g = question, count = 0;
while (g.phase === 5 && g.active === f.target && count++ < 20) {
if (!clean(g) && !g.truthtrance) { g = advance(g, clean); continue; }
const view = viewGame(g, f.target); view.players.find(p => p.id === f.target)!.bot = difficulty;
const action = botActions(view)[0]; assert.ok(action);
g = act(g, f.target, action);
}
assert.ok(count < 20); assert.equal(g.movementPromises![0].fulfilled, true);
}
});