Files
Dune/tests/truth-movement-shipment-engine.test.ts
Quintine f0b2ac82d9 Support Basic movement promises before shipment
Allow clean active Basic ground-movement questions before or after shipment. Enumerate ordinary reserve amounts, native physical allocations, destinations and pledged payment splits through the actual declaration and shared physical commit. Detached projection stops before income/arrival effects and never counts shipment as ground fulfillment. Joint movement No and shipment Yes retain a legal next action rather than invalid End guidance.

Preserve exact pending ordinary reserve and native Guild permission/rate declarations when checking the remaining route. Voluntary wrong destination, force splitting or fee loss cannot release Yes through an opponent response. Guard unsupported sources before full speculative continuation. Existing live payment, cancellation, income and entry paths remain. Persistent busy-safe controls display reinforcement and both obligations; all four existing profiles use the same legal witness without strategy refinement.

Verify 19 files165/165, types/lint/build, six original-action gameplay/control/table-rendering programmes and four profiles with16 accepted actions. Genuine sources19/37/24/19 retain classic Arrakeen10 reinforced to11, native zero-board Emperor/CHOAM starts and original Guild5/15/5 reserve/cross transport. Second-card/counter cases explicitly transfer conserved originals. Bounded review repaired source guarding, funding completeness and exact pending-frame behavior. Stop an observed expensive search, reorder complete candidates by useful proximity and conservatively prune dead-end final moves; twelve new engine cases pass in3.2s. Correct a display-template lint issue.

Earlier phases/waiting turns, native transport/relocation preparation, No-Field/module sources, broader interactions, full modes, comprehensive assurance and deployed acceptance remain open. No strategic tuning or saved-room/reset/restart/deployment operation.
2026-10-24 15:10:49 +11:00

164 lines
11 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { actTruthMovement as act } from './fixture-truth-movement';
import { createTruthMovementShipmentFixture as classic, createTruthMovementShipmentChoamFixture as choam,
createTruthMovementShipmentEmperorFixture as emperor, createTruthMovementShipmentGuildFixture as guild,
truthMovementShipmentInventory as inventory } from './fixture-truth-movement-shipment';
function ask(game: Game, asker: string, card: string, question: Record<string, unknown>) {
game = act(game, asker, { type: 'card', card });
for (const p of game.players) if (!game.truthtrance!.passed.includes(p.id)) game = act(game, p.id, { type: 'truthPass' });
return act(game, asker, { type: 'truthAsk', question });
}
void test('a genuine active pre-shipment turn can promise an eleven-force movement using actual reserve shipment', () => {
const f = classic({ hajr: false });
const game = ask(f.game, f.asker, f.truth, f.claim);
assert.ok(viewGame(game, f.target).truthMovementAnswers!.includes('yes'));
});
function pass(game: Game): Game {
if (game.pendingTreacheryDiscard?.reaction) {
const event = game.pendingTreacheryDiscard.batch.event;
for (const p of game.players) game = act(game, p.id, { type: 'semutaPass', event });
}
while (game.response) {
const p = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(p);
game = act(game, p.id, { type: 'passResponse' });
}
return game;
}
function bindClassic(answer = 'yes') {
const f = classic({ hajr: false });
let game = ask(f.game, f.asker, f.truth, f.claim);
game = pass(act(game, f.target, { type: 'truthAnswer', answer }));
return { f, game };
}
void test('real city reinforcement pays once, leaves the ground promise pending and moves the combined eleven forces', () => {
const { f, game: bound } = bindClassic(), before = structuredClone(bound);
const first = viewGame(bound, f.target).movementPromisePolicy![0];
assert.equal(first.type, 'ship'); assert.equal(first.territory, 'arrakeen'); assert.equal(first.amount, 1);
assert.deepEqual(bound, before);
let game = pass(act(bound, f.target, first));
const owner = game.players.find(p => p.id === f.target)!;
assert.equal(owner.spice, f.balances[f.target] - 1); assert.equal(owner.reserves, 9);
assert.equal(owner.forces['arrakeen:10'], 11); assert.equal(game.movementPromises![0].fulfilled, undefined);
game = act(game, f.target, viewGame(game, f.target).movementPromisePolicy![0]);
assert.equal(game.movementPromises![0].fulfilled, true);
game = act(game, f.target, { type: 'endMovement' });
assert.deepEqual(inventory(game), f.originalCards);
});
void test('voluntary shipment away from the only reinforcing origin cannot erase the accepted eleven-force route', () => {
const { f, game } = bindClassic(), before = structuredClone(game);
assert.throws(() => act(game, f.target, { ...f.shipment, territory: 'carthag', sector: 11 }));
assert.deepEqual(game, before);
assert.throws(() => act(game, f.target, { type: 'endMovement' }));
});
void test('No allows a matching-destination shipment but still forbids the subsequent qualifying ground movement', () => {
const f = classic({ hajr: false });
let game = ask(f.game, f.asker, f.truth, { ...f.claim, minimum: 1 });
game = act(game, f.target, { type: 'truthAnswer', answer: 'no' });
game = pass(act(game, f.target, { ...f.shipment, territory: 'carthag', sector: 11 }));
assert.equal(game.movementPromises![0].fulfilled, undefined);
assert.throws(() => act(game, f.target, { ...f.move, amount: 1 }));
game = act(game, f.target, { type: 'endMovement' }); assert.equal(game.movementPromises![0].fulfilled, true);
});
void test('genuine zero-board native Emperor and CHOAM sources can promise movement before their actual shipment', () => {
for (const f of [emperor(), choam()]) {
let game = ask(f.game, f.asker, f.truth, f.claim);
assert.ok(viewGame(game, f.target).truthMovementAnswers!.includes('yes'));
game = pass(act(game, f.target, { type: 'truthAnswer', answer: 'yes' }));
assert.equal(viewGame(game, f.target).movementPromisePolicy![0].type, 'ship');
game = pass(act(game, f.target, f.shipment));
assert.equal(game.movementPromises![0].fulfilled, undefined);
if ('kulon' in f) game = pass(act(game, f.target, { type: 'card', mode: 'choam', card: f.kulon }));
game = pass(act(game, f.target, viewGame(game, f.target).movementPromisePolicy![0]));
assert.equal(game.movementPromises![0].fulfilled, true); assert.deepEqual(inventory(game), f.originalCards);
}
});
void test('the voluntary bribe quote retains the one-spice reinforcement needed by the eleven-force route', () => {
const { f, game } = bindClassic(), before = structuredClone(game);
const maximum = viewGame(game, f.target).bribeOptions.targets.find(p => p.id === f.asker)!.maximum;
assert.equal(maximum, f.balances[f.target] - 1);
assert.throws(() => act(game, f.target, { type: 'bribe', target: f.asker, amount: maximum + 1 }));
assert.deepEqual(game, before);
const after = act(game, f.target, { type: 'bribe', target: f.asker, amount: maximum });
assert.equal(viewGame(after, f.target).movementPromisePolicy![0].type, 'ship');
});
void test('a controlled conserved second Truth answer keeps shipment Yes ahead of movement No and never suggests illegal End', () => {
const f = classic({ hajr: false }), source = structuredClone(f.game);
const extra = source.deck.splice(source.deck.findIndex(card => card.effect === 'truthtrance'), 1)[0]; assert.ok(extra);
source.players.find(p => p.id === f.asker)!.hand.push(extra);
let game = ask(source, f.asker, f.truth, { kind: 'shipment', target: f.target, territory: 'arrakeen', minimum: 1 });
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
game = ask(game, f.asker, extra.id, f.claim); game = act(game, f.target, { type: 'truthAnswer', answer: 'no' });
const first = viewGame(game, f.target).movementPromisePolicy![0]; assert.equal(first.type, 'ship');
game = pass(act(game, f.target, first));
assert.equal(game.shipmentPromises![0].fulfilled, true); assert.equal(game.movementPromises![0].fulfilled, undefined);
assert.deepEqual(viewGame(game, f.target).movementPromisePolicy, [{ type: 'endMovement' }]);
game = act(game, f.target, { type: 'endMovement' }); assert.equal(game.movementPromises![0].fulfilled, true);
assert.deepEqual(inventory(game), f.originalCards);
});
function counterSource() {
const f = guild(), source = structuredClone(f.game);
const counter = source.deck.splice(source.deck.findIndex(card => card.effect === 'karama'), 1)[0]; assert.ok(counter);
source.players.find(p => p.id === f.asker)!.hand.push(counter);
return { f, source, counter };
}
void test('a controlled acquired counter holds the real Guild-rate declaration until its original allowed shipment', () => {
const { f, source } = counterSource(); let game = ask(source, f.asker, f.truth, f.claim);
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
game = act(game, f.target, f.discountedShipment);
assert.equal(game.response?.kind, 'guildRate'); assert.equal(game.pendingShipment?.amount, 2);
const before = structuredClone(game); viewGame(game, f.target); viewGame(game, f.asker); assert.deepEqual(game, before);
assert.equal(game.movementPromises![0].fulfilled, undefined); assert.equal(game.movementPromises![0].released, undefined);
game = pass(game);
assert.equal(game.players.find(p => p.id === f.target)!.spice, 4);
assert.equal(game.players.find(p => p.id === f.target)!.forces['arrakeen:10'], 2);
game = act(game, f.target, f.move); assert.equal(game.movementPromises![0].fulfilled, true);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('a controlled acquired counter raises the real Guild price and releases an impossible seven-force promise', () => {
const { f, source, counter } = counterSource();
let game = ask(source, f.asker, f.truth, { ...f.claim, territory: 'south_mesa', minimum: 7 });
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
game = act(game, f.target, { type: 'bribe', target: f.asker, amount: 4 });
game = act(game, f.target, { ...f.discountedShipment, territory: 'tueks_sietch', sector: 5 });
assert.equal(game.response?.kind, 'guildRate');
game = act(game, f.asker, { type: 'card', card: counter.id, mode: 'cancel' });
const own = game.players.find(p => p.id === f.target)!;
assert.equal(own.spice, 1); assert.equal(own.reserves, 15); assert.equal(own.shipped, false);
assert.deepEqual(own.forces, { 'tueks_sietch:5': 5 });
assert.equal(game.movementPromises![0].released, true); assert.deepEqual(inventory(game), f.originalCards);
});
void test('controlled acquired-counter Guild cross-shipment preserves the exact permission and rate frames before ground fulfillment', () => {
const { f, source } = counterSource(); let game = ask(source, f.asker, f.truth, f.claim);
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
game = act(game, f.target, f.crossShipment);
assert.ok(game.pendingGuildTransport); const before = structuredClone(game);
viewGame(game, f.target); assert.deepEqual(game, before);
game = pass(game);
const own = game.players.find(p => p.id === f.target)!;
assert.equal(own.reserves, 15); assert.equal(own.spice, 4); assert.equal(own.forces['arrakeen:10'], 2);
game = act(game, f.target, f.move); assert.equal(game.movementPromises![0].fulfilled, true);
});
void test('controlled acquired-counter Guild return cannot split away the only five-force ground group', () => {
const { f, source } = counterSource();
let game = ask(source, f.asker, f.truth, { ...f.claim, territory: 'south_mesa', minimum: 5 });
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' }); const before = structuredClone(game);
assert.throws(() => act(game, f.target, f.returnShipment)); assert.deepEqual(game, before);
});
void test('all four minimal profiles reinforce the original ten-force group and finish its promised movement', () => {
const f = classic({ hajr: false }), asked = ask(f.game, f.asker, f.truth, f.claim);
for (const difficulty of DIFFICULTIES) {
let game = asked, count = 0;
while (game.phase === 5 && game.active === f.target && count++ < 12) {
const view = viewGame(game, f.target); view.players.find(p => p.id === f.target)!.bot = difficulty;
const action = botActions(view)[0]; assert.ok(action); game = pass(act(game, f.target, action));
}
assert.ok(count < 12); assert.equal(game.movementPromises![0].fulfilled, true);
assert.deepEqual(inventory(game), f.originalCards);
}
});