163 lines
5.5 KiB
TypeScript
163 lines
5.5 KiB
TypeScript
import type { Game } from './engine';
|
|
import { homeworldContext } from './homeworld-game';
|
|
import { homeworldPopulations } from './homeworld-population';
|
|
|
|
type HomeworldBenefitContext = Pick<
|
|
Game,
|
|
'advanced' | 'players' | 'homeworlds'
|
|
>;
|
|
export type HomeworldRevivalOpening = {
|
|
turn: number;
|
|
tleilaxu: { player: string; low: boolean };
|
|
};
|
|
export class HomeworldBenefitError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'HomeworldBenefitError';
|
|
}
|
|
}
|
|
|
|
/** Current native population only. The caller owns benefit timing and all
|
|
* ordinary eligibility; Salusa never supplies another low-population bonus. */
|
|
export function homeworldLowBonus(
|
|
context: HomeworldBenefitContext,
|
|
playerId: string,
|
|
): 0 | 1 {
|
|
if (!context.homeworlds?.custody) return 0;
|
|
return homeworldPopulations(
|
|
homeworldContext(context),
|
|
context.homeworlds.custody,
|
|
).some(
|
|
(home) =>
|
|
home.native === playerId &&
|
|
home.card !== 'salusa_secundus' &&
|
|
home.side === 'low',
|
|
)
|
|
? 1
|
|
: 0;
|
|
}
|
|
|
|
/** Salusa's threshold counts Sardaukar at Salusa, not all Imperial reserves.
|
|
* Its high advantage affects the faction's Sardaukar wherever they fight. */
|
|
export function homeworldSardaukarFreeSupport(
|
|
context: HomeworldBenefitContext,
|
|
playerId: string,
|
|
): boolean {
|
|
if (!context.advanced || !context.homeworlds?.custody) return false;
|
|
if (
|
|
!context.players.some((p) => p.id === playerId && p.faction === 'emperor')
|
|
)
|
|
return false;
|
|
return homeworldPopulations(
|
|
homeworldContext(context),
|
|
context.homeworlds.custody,
|
|
).some(
|
|
(home) =>
|
|
home.native === playerId &&
|
|
home.card === 'salusa_secundus' &&
|
|
home.side === 'high',
|
|
);
|
|
}
|
|
|
|
/** Current population can change before sealed plans. The caller renews an
|
|
* impossible inspection answer after the return. The remaining preview boundary
|
|
* protects sealed dials/support and binding Truthtrance promises from repricing;
|
|
* it does not freeze population or forbid Ghola for a whole battle.
|
|
* Inspect submission presence, never an opponent's sealed plan contents. */
|
|
export function homeworldSardaukarGholaBlock(
|
|
context: HomeworldBenefitContext & Pick<Game, 'battle'>,
|
|
playerId: string,
|
|
): string | null {
|
|
if (
|
|
!context.advanced ||
|
|
!context.homeworlds?.custody ||
|
|
!context.battle ||
|
|
![context.battle.attacker, context.battle.defender].includes(playerId) ||
|
|
!context.players.some((p) => p.id === playerId && p.faction === 'emperor')
|
|
)
|
|
return null;
|
|
const crossing = homeworldPopulations(
|
|
homeworldContext(context),
|
|
context.homeworlds.custody,
|
|
).some(
|
|
(home) =>
|
|
home.native === playerId &&
|
|
home.card === 'salusa_secundus' &&
|
|
home.population === 1,
|
|
);
|
|
if (!crossing) return null;
|
|
const battle = context.battle;
|
|
const committed = battle.revealed || Object.keys(battle.plans).length > 0 ||
|
|
battle.truthPromises?.some((promise) => !promise.released);
|
|
return committed
|
|
? 'Ghola raising Salusa to high population after a sealed plan or during a binding battle promise is not yet supported. Finish this battle first.'
|
|
: null;
|
|
}
|
|
|
|
const validTurn = (turn: unknown): turn is number =>
|
|
typeof turn === 'number' && Number.isSafeInteger(turn) && turn > 0;
|
|
|
|
/** Capture before any revival-opening effect can change native population.
|
|
* A saved receipt owns this phase's Tleilaxu free-revival income restriction. */
|
|
export function snapshotHomeworldRevival(
|
|
context: HomeworldBenefitContext & Pick<Game, 'turn'>,
|
|
): HomeworldRevivalOpening | null {
|
|
if (!context.homeworlds?.custody) return null;
|
|
const tleilaxu = context.players.find(
|
|
(player) => player.faction === 'tleilaxu',
|
|
);
|
|
if (!tleilaxu) return null;
|
|
if (!validTurn(context.turn))
|
|
throw new HomeworldBenefitError(
|
|
'The Homeworld revival opening needs a valid current turn.',
|
|
);
|
|
return {
|
|
turn: context.turn,
|
|
tleilaxu: {
|
|
player: tleilaxu.id,
|
|
low: homeworldLowBonus(context, tleilaxu.id) === 1,
|
|
},
|
|
};
|
|
}
|
|
|
|
const record = (value: unknown): value is Record<string, unknown> =>
|
|
!!value && typeof value === 'object' && !Array.isArray(value);
|
|
|
|
/** Read only the saved opening decision. Never infer a missing receipt from
|
|
* current populations: earlier revivals may already have crossed a threshold.
|
|
* This answers only the free-revival income gate, not Ghola purchase income. */
|
|
export function tleilaxuHomeworldFreeIncomeBlocked(
|
|
context: HomeworldBenefitContext &
|
|
Pick<Game, 'turn' | 'phase'> & {
|
|
homeworldRevival?: HomeworldRevivalOpening | null;
|
|
},
|
|
): boolean {
|
|
if (context.phase !== 4 || !context.homeworlds) return false;
|
|
const tleilaxu = context.players.filter(
|
|
(player) => player.faction === 'tleilaxu',
|
|
);
|
|
if (!tleilaxu.length) return false;
|
|
const receipt: unknown = context.homeworldRevival;
|
|
if (
|
|
tleilaxu.length !== 1 ||
|
|
!validTurn(context.turn) ||
|
|
!record(receipt) ||
|
|
Object.keys(receipt).length !== 2 ||
|
|
!Object.hasOwn(receipt, 'turn') ||
|
|
!Object.hasOwn(receipt, 'tleilaxu') ||
|
|
receipt.turn !== context.turn ||
|
|
!record(receipt.tleilaxu) ||
|
|
Object.keys(receipt.tleilaxu).length !== 2 ||
|
|
!Object.hasOwn(receipt.tleilaxu, 'player') ||
|
|
!Object.hasOwn(receipt.tleilaxu, 'low') ||
|
|
typeof receipt.tleilaxu.player !== 'string' ||
|
|
!receipt.tleilaxu.player.trim() ||
|
|
receipt.tleilaxu.player !== tleilaxu[0].id ||
|
|
typeof receipt.tleilaxu.low !== 'boolean'
|
|
)
|
|
throw new HomeworldBenefitError(
|
|
'The current Tleilaxu Homeworld revival-opening receipt is missing, stale or malformed.',
|
|
);
|
|
return receipt.tleilaxu.low;
|
|
}
|