Files
Dune/game/movement-promise-search.ts
T

155 lines
11 KiB
TypeScript

import type { Action, Game, Player } from './engine';
import { gameTerritories, splitLocation } from './board';
import { liveMovementPromises, movementEventAllowed, movementMatches, settleMovementEvent, type MovementEvent, type MovementPromise } from './movement-promises';
import { evaluateMovementExpression, liveMovementExpressionPromises, movementExpressionTerritories,
MovementExpressionPromiseError, recordMovementExpressionEvent, type MovementExpression,
type MovementExpressionPromise } from './movement-expression-promises';
export type MovementPromiseAdapter = {
allowance: (game: Game, player: Player) => number;
canFinish: (game: Game, player: string) => boolean;
/** A required preparation may itself need a real first move, such as the
* native Advanced Hajr window, even without a positive destination claim. */
needsProgress?: (game: Game, player: string) => boolean;
progressKey?: (game: Game, player: string) => unknown;
preparations: (game: Game, player: string) => Iterable<{ game: Game; actions: Action[] }>;
reachable: (game: Game, player: Player, origin: string, destination: string, sector: number) => boolean;
project: (game: Game, player: string, action: Action) => { game: Game; event: MovementEvent } | null;
};
export type MovementCompletion = { actions: Action[]; early?: { phase: number; action: Action }[] };
/** Enumerate actual physical subgroups from one territory, including native
* elite allocations and several sectors. No strategic shortlist proves legality. */
function* groups(player: Player, keys: string[], amount: number, index = 0,
forces: Record<string, number> = {}, elites: Record<string, number> = {}): Generator<Action> {
if (index === keys.length) {
if (amount === 0) yield { type: 'move', forces: { ...forces }, eliteForces: { ...elites } };
return;
}
const key = keys[index], special = player.elites?.forces[key] ?? 0;
const ordinary = player.forces[key] - special;
for (let elite = 0; elite <= Math.min(amount, special); elite++)
for (let normal = Math.min(ordinary, amount - elite); normal >= 0; normal--) {
const count = elite + normal;
if (count) forces[key] = count; else delete forces[key];
if (elite) elites[key] = elite; else delete elites[key];
yield* groups(player, keys, amount - count, index + 1, forces, elites);
}
delete forces[key]; delete elites[key];
}
/** Finite owned-action reachability. Each move consumes a real entitlement;
* Hajr, Ornithopter, Ghola, Kulon and Box use owned physical cards and known or
* already-paid recovery choices, never invented unknown future draws.
* The adapter shares authoritative movement validation/physical commitment.
* Opposing arrival outcomes are not simulated or assumed. */
export function findMovementCompletion(source: Game, actor: string,
promises: readonly MovementPromise[], adapter: MovementPromiseAdapter,
expressions: readonly MovementExpressionPromise[] = source.movementExpressionPromises ?? []): MovementCompletion | null {
const failed = new Set<string>();
const retainWhole = expressions.length > 0 || source.movementExpressionPromises !== undefined;
const search = (game: Game, pending: readonly MovementPromise[], whole: readonly MovementExpressionPromise[],
actions: Action[]): MovementCompletion | null => {
const live = liveMovementPromises(pending, actor, game.turn);
const liveWhole = liveMovementExpressionPromises(whole, actor, game.turn);
const positiveWhole = liveWhole.filter(promise => promise.answer);
const needsMove = live.some(promise => promise.answer) || positiveWhole.length > 0;
if (!needsMove && adapter.canFinish(game, actor)) return { actions };
const player = game.players.find(candidate => candidate.id === actor)!;
const physical = Object.values(player.forces).reduce((sum, amount) => sum + amount, 0);
// A held recovery card may return more than five over several real plays
// or Box reuse. Tanks are only an optimistic bound; the adapter still
// validates every owned card and never inspects unknown Box faces here.
const recovery = player.hand.some(card => card.effect === 'ghola' || card.effect === 'nullentropyBox') ? player.tanks : 0;
const marker = player.faction === 'richese' ? player.noField?.deployed : null;
const markerValue = marker ? player.noField!.tokens.find(token => token.id === marker.tokenId)?.value ?? 0 : 0;
const revealable = Math.min(markerValue, player.reserves + recovery);
const possiblePhysical = physical + (player.shipped ? revealable : player.reserves + recovery);
if (live.some(promise => promise.answer && promise.minimum > possiblePhysical) ||
positiveWhole.some(promise => !evaluateMovementExpression(promise.claim,
movementExpressionTerritories(promise.claim).map(territory => ({ territory,
maximum: Math.max(promise.progress.find(entry => entry.territory === territory)?.maximum ?? 0,
Math.min(20, possiblePhysical)) }))))) return null;
const key = JSON.stringify([player.forces, player.elites, player.advisors, player.moved, player.shipped, player.reserves, player.tanks, player.hand.map(card => card.id),
player.hajrPlays, player.hajrWindow, player.ornithopterExtraMoves, player.spice, game.choamMovement,
player.fremenMovementBlocked, player.ixMovementBlocked, player.advisorFlipMovementBlocked, game.advisorMatchingDenied,
player.noField, player.noFieldEvent,
game.advisorPreparation,
game.aid, game.karamaShipping, game.guildRateBlocked, game.shipmentPromises,
game.discard.map(card => card.id), game.movementDiscardKnowledge?.[actor], game.pendingNullentropy,
game.choamWorthlessBlocked, game.hajr, game.ornithopter ? [game.ornithopter.mode, game.ornithopter.startingMove, game.ornithopter.completed, game.ornithopter.cohort] : null,
live.map(promise => [promise.territory, promise.minimum, promise.answer]),
liveWhole.map(promise => [promise.claim, promise.answer, promise.progress]), adapter.progressKey?.(game, actor)]);
if (failed.has(key)) return null;
const destinations = gameTerritories(game).filter(destination => !destination.id.startsWith('homeworld:'));
const wanted = new Set([...live.filter(promise => promise.answer).map(promise => promise.territory),
...positiveWhole.flatMap(promise => movementExpressionTerritories(promise.claim))]);
destinations.sort((a, b) => Number(wanted.has(b.id)) - Number(wanted.has(a.id)) ||
(marker ? Number(['arrakeen', 'carthag'].includes(b.id)) - Number(['arrakeen', 'carthag'].includes(a.id)) : 0));
if ((needsMove || adapter.needsProgress?.(game, actor)) && (marker || !live.some(promise => promise.answer && promise.minimum > physical)) &&
!game.pendingNullentropy && player.moved < adapter.allowance(game, player)) {
const origins = [...new Set([...Object.keys(player.forces).filter(key => player.forces[key] > 0)
.map(key => splitLocation(key).territory), ...(marker ? [marker.location.territory] : [])])];
const card = !game.ornithopter ? player.hand.find(card => card.effect === 'ornithopter') : null;
const modes: Partial<Action>[] = game.ornithopter
? [{ ornithopterEvent: game.ornithopter.event }]
: [{}, ...(card ? [{ movementCard: card.id, ornithopter: 'range3' },
{ movementCard: card.id, ornithopter: 'twoGroups' }] : [])];
const stances: Partial<Action>[] = game.advanced && player.faction === 'beneGesserit'
? [{}, { fighters: true }] : [{}];
// An exhausted ordinary move cannot leave Yes pending without an owned
// card that could grant another move. Treat held Box/Hajr/Ornithopter
// conservatively; their actual availability remains the adapter's job.
const mayMoveAgain = player.moved + 1 < adapter.allowance(game, player) || !!game.ornithopter ||
player.hand.some(card => ['hajr', 'ornithopter', 'nullentropyBox'].includes(card.effect ?? ''));
for (const destination of destinations) for (const sector of destination.sectors) for (const origin of origins) {
if (!adapter.reachable(game, player, origin, destination.id, sector)) continue;
const keys = Object.keys(player.forces).filter(key => player.forces[key] > 0 && splitLocation(key).territory === origin);
const total = keys.reduce((sum, key) => sum + player.forces[key], 0);
const carriesMarker = marker?.location.territory === origin && !!player.noFieldEvent;
const amounts = [...(carriesMarker ? [0] : []), ...Array.from({ length: total }, (_, i) => i + 1)];
const helpsWhole = (expression: MovementExpression, amount: number): boolean => 'terms' in expression
? expression.terms.some(term => helpsWhole(term, amount))
: expression.territory === destination.id && amount >= expression.minimum;
const helps = (amount: number) => live.some(p => p.answer && p.territory === destination.id && amount >= p.minimum) ||
positiveWhole.some(promise => helpsWhole(promise.claim, amount));
amounts.sort((a, b) => Number(helps(b)) - Number(helps(a)) || a - b);
for (const amount of amounts) {
const event = { territory: destination.id, amount };
if (!movementEventAllowed(live, event)) continue;
if (!mayMoveAgain && live.some(promise => promise.answer && !movementMatches(promise, event))) continue;
let projectedWhole: MovementExpressionPromise[];
try { projectedWhole = recordMovementExpressionEvent(whole, actor, game.turn, event); }
catch (error) { if (error instanceof MovementExpressionPromiseError) continue; throw error; }
if (!mayMoveAgain && liveMovementExpressionPromises(projectedWhole, actor, game.turn).some(promise => promise.answer)) continue;
const markers: Partial<Action>[] = carriesMarker
? [...(amount ? [{}] : []), { noField: marker!.tokenId, event: player.noFieldEvent }] : [{}];
for (const group of groups(player, keys, amount)) for (const mode of modes) for (const stance of stances) for (const selected of markers) {
const action = { ...group, ...mode, ...stance, ...selected, territory: destination.id, sector };
const next = adapter.project(game, actor, action);
if (!next) continue;
const settled = settleMovementEvent(pending, actor, game.turn, next.event);
// The projection owns the exact physical amount (markers excluded).
// Keep promise progress on detached states only.
let settledWhole: MovementExpressionPromise[];
try { settledWhole = recordMovementExpressionEvent(whole, actor, game.turn, next.event); }
catch (error) { if (error instanceof MovementExpressionPromiseError) continue; throw error; }
next.game.movementPromises = structuredClone(settled);
if (retainWhole) next.game.movementExpressionPromises = structuredClone(settledWhole);
const result = search(next.game, settled, settledWhole, [...actions, action]);
if (result) return result;
}
}
}
}
for (const prepared of adapter.preparations(game, actor)) {
const result = search(prepared.game, live, whole, [...actions, ...prepared.actions]);
if (result) return result;
}
failed.add(key); return null;
};
const initial = structuredClone(source); initial.movementPromises = structuredClone([...promises]);
if (retainWhole) initial.movementExpressionPromises = structuredClone([...expressions]);
return search(initial, promises, expressions, []);
}