Files
Dune/game/homeworld-revival-deployment-options.ts

132 lines
5.9 KiB
TypeScript

import { gameTerritories, splitLocation } from './board';
import type { Action, GameView } from './engine';
/** Use the server's source-specific timing boundary for the selected group. */
export function homeworldRevivalActionBlock(g: GameView, action: Action): string | null {
const me = g.players.find((player) => player.id === g.me);
if (!me || action.leader) return null;
const source = action.type === 'revive'
? 'normal'
: action.type === 'card' && me.hand?.some((card) => card.id === action.card && card.effect === 'ghola')
? 'ghola'
: null;
if (!source) return null;
const amount = action.amount === undefined && source === 'ghola'
? Math.min(5, me.tanks)
: Number(action.amount);
const elite = action.elite === undefined
? Math.max(0, amount - (me.tanks - (me.elites?.tanks ?? 0)))
: Number(action.elite);
return g.homeworldRevivalBlocks?.find((block) =>
block.source === source && block.amount === amount && block.elite === elite)?.reason ?? null;
}
/** Consume the actor's projected destinations; never reconstruct eligibility
* from reserves or another player's private state. */
export function homeworldRevivalDeploymentCanAct(g: GameView): boolean {
return !!(
g.status === 'playing' &&
g.homeworldRevivalDeployment?.player === g.me &&
g.decision?.kind === 'homeworldRevivalDeployment' &&
g.decision.player === g.me &&
g.decision.event === g.homeworldRevivalDeployment.event &&
!g.response &&
!g.truthtrance &&
!g.phaseOpening &&
!g.automaticContinuationPending
);
}
function deploymentQuantities(g: GameView): { amounts: number[]; blocked: string | null } {
const offer = g.homeworldRevivalDeployment;
if (!offer || !Number.isSafeInteger(offer.normal) || !Number.isSafeInteger(offer.elite) ||
offer.normal < 0 || offer.elite < 0 || offer.normal + offer.elite <= 0 || offer.normal + offer.elite > 20)
return { amounts: [], blocked: 'The revived group is no longer available.' };
const allocation = offer.allocation;
if (allocation === undefined) return { amounts: [offer.normal + offer.elite], blocked: null };
if (!allocation || typeof allocation !== 'object' || Array.isArray(allocation) ||
Object.keys(allocation).sort().join(',') !== 'kept,placed,remaining,total' || g.advanced || offer.kind !== 'fedaykin' || offer.normal !== 0 ||
![allocation.total, allocation.remaining, allocation.placed, allocation.kept].every(value => Number.isSafeInteger(value) && value >= 0 && value <= 20) ||
allocation.remaining <= 0 || allocation.total <= 0 || allocation.total !== allocation.remaining + allocation.placed + allocation.kept ||
offer.elite !== allocation.remaining)
return { amounts: [], blocked: 'The Southern revival allocation is no longer available.' };
return { amounts: Array.from({ length: allocation.remaining }, (_, index) => index + 1), blocked: null };
}
/** Only the stamped native Basic allocation allows a partial placement. */
export function homeworldRevivalDeploymentQuantities(g: GameView): number[] {
if (!homeworldRevivalDeploymentCanAct(g) || g.homeworldRevivalDeployment?.blocked) return [];
return deploymentQuantities(g).amounts;
}
export function homeworldRevivalDeploymentChoice(
g: GameView,
destination: string,
amount?: number,
): { blocked: string | null; action: Action | null } {
const offer = g.homeworldRevivalDeployment;
const target = offer?.destinations.find(
(option) => option.id === destination,
);
const quantities = deploymentQuantities(g);
const selected = amount === undefined ? quantities.amounts[0] : amount;
let blocked: string | null = null;
if (!homeworldRevivalDeploymentCanAct(g))
blocked = 'Wait for the current choice to finish.';
else if (offer!.blocked) blocked = offer!.blocked;
else if (!target) blocked = 'Choose a current revival destination.';
else if (target.blocked) blocked = target.blocked;
else if (quantities.blocked) blocked = quantities.blocked;
else if (selected === undefined || !quantities.amounts.includes(selected))
blocked = 'Choose a current revived quantity.';
return {
blocked,
action: blocked
? null
: {
type: 'decision',
event: offer!.event,
destination,
amount: selected,
},
};
}
/** All profiles share this legal fallback: reinforce an occupied stronghold,
* otherwise use the first permitted destination, or leave the group at home. */
export function homeworldRevivalDeploymentActions(g: GameView): Action[] {
if (!homeworldRevivalDeploymentCanAct(g)) return [];
const offer = g.homeworldRevivalDeployment!;
const me = g.players.find((player) => player.id === g.me);
const strongholds = new Set(
gameTerritories(g)
.filter((territory) => territory.type === 'stronghold')
.map((territory) => territory.id),
);
const occupied = new Set(
Object.entries(me?.forces ?? {})
.filter(([, count]) => count > 0)
.map(([key]) => splitLocation(key).territory),
);
const quantities = homeworldRevivalDeploymentQuantities(g);
const choices = offer.destinations
.flatMap((target) => quantities.map(amount => ({
target,
action: homeworldRevivalDeploymentChoice(g, target.id, amount).action,
})))
.filter((choice) => choice.action);
const preferred = choices.find(
({ target }) =>
target.territory &&
strongholds.has(target.territory) &&
occupied.has(target.territory),
);
const action = preferred?.action ?? choices[0]?.action;
if (offer.allocation !== undefined) {
const ordered = preferred ? [preferred, ...choices.filter(choice => choice !== preferred && choice.target.id === preferred.target.id),
...choices.filter(choice => choice.target.id !== preferred.target.id)] : choices;
return [...ordered.flatMap(choice => choice.action ? [choice.action] : []), { type: 'decision', event: offer.event, decline: true }];
}
return [action ?? { type: 'decision', event: offer.event, decline: true }];
}