215 lines
12 KiB
TypeScript
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);
|
|
}
|
|
});
|