132 lines
5.9 KiB
TypeScript
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 }];
|
|
}
|