Use exact physical Ghola preparation and known/paid Box recovery without crediting conditional income or normal revival allowances. Guide real force return, paid shipment and qualifying ground movement; retain the existing incomplete-mode and final acceptance boundaries.
109 lines
7.3 KiB
TypeScript
109 lines
7.3 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[]; 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): 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);
|
|
// 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;
|
|
if (live.some(promise => promise.answer && promise.minimum > physical + (player.shipped ? 0 : player.reserves + recovery))) return null;
|
|
const key = JSON.stringify([player.forces, player.elites, player.moved, player.shipped, player.reserves, player.tanks, 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, []);
|
|
}
|