Files
Dune/game/movement-promise-search.ts
T
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

105 lines
6.9 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';
export type MovementPromiseAdapter = {
allowance: (game: Game, player: Player) => number;
canFinish: (game: Game, player: string) => boolean;
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[] };
/** 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, 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): MovementCompletion | null {
const failed = new Set<string>();
const search = (game: Game, pending: readonly MovementPromise[], actions: Action[]): MovementCompletion | null => {
const live = liveMovementPromises(pending, actor, game.turn);
const needsMove = live.some(promise => promise.answer);
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);
if (live.some(promise => promise.answer && promise.minimum > physical + (player.shipped ? 0 : player.reserves))) return null;
const key = JSON.stringify([player.forces, player.elites, player.moved, player.shipped, player.reserves, player.hand.map(card => card.id),
player.hajrPlays, player.ornithopterExtraMoves, player.spice, game.choamMovement,
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])]);
if (failed.has(key)) return null;
const destinations = gameTerritories(game).filter(destination => !destination.id.startsWith('homeworld:'));
destinations.sort((a, b) => Number(live.some(p => p.answer && p.territory === b.id)) -
Number(live.some(p => p.answer && p.territory === a.id)));
if (needsMove && !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))];
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' }] : [])];
// 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 amounts = Array.from({ length: total }, (_, i) => i + 1);
amounts.sort((a, b) => Number(live.some(p => p.answer && p.territory === destination.id && b >= p.minimum)) -
Number(live.some(p => p.answer && p.territory === destination.id && a >= p.minimum)) || 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;
for (const group of groups(player, keys, amount)) for (const mode of modes) {
const action = { ...group, ...mode, territory: destination.id, sector };
const next = adapter.project(game, actor, action);
if (!next) continue;
const settled = settleMovementEvent(pending, actor, game.turn, next.event);
next.game.movementPromises = structuredClone(settled);
const result = search(next.game, settled, [...actions, action]);
if (result) return result;
}
}
}
}
for (const prepared of adapter.preparations(game, actor)) {
const result = search(prepared.game, live, [...actions, ...prepared.actions]);
if (result) return result;
}
failed.add(key); return null;
};
const initial = structuredClone(source); initial.movementPromises = structuredClone([...promises]);
return search(initial, promises, []);
}