import type { Game } from './engine'; import { coalitionOf, CoalitionAllianceError } from './coalition-alliance'; export type FremenRevivalGrantSource = { turn: number; owner: string; recipient: string; }; export type FremenRevivalGrantContext = Pick< Game, 'status' | 'phase' | 'turn' | 'players' | 'freeRevival' | 'revivalRules' | 'recruits' > & Partial>; export class FremenRevivalGrantError extends Error { constructor(message: string) { super(message); this.name = 'FremenRevivalGrantError'; } } const id = (value: unknown): value is string => typeof value === 'string' && value.length > 0; const count = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 0; /** Match the unexposed Basic classic group profile; other profiles retain the * existing mutual-pair path until their interactions are connected. */ function groupRecipients(g: FremenRevivalGrantContext, owner: string): string[] | null { if (!g.coalitionState || g.status !== 'playing' || g.advanced !== false || !Array.isArray(g.expansions) || g.expansions.length || g.homeworlds || g.nexusCards || g.leaderSkills || g.techTokens || g.strongholdCards || g.ecazTreachery || g.discoveryEnabled || !g.players.every(player => ['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'].includes(player.faction))) return null; try { const group = coalitionOf(g.players, g.coalitionState, owner); return group && group.members.length >= 3 ? group.members.filter(member => member !== owner) : null; } catch (error) { if (error instanceof CoalitionAllianceError) throw new FremenRevivalGrantError(error.message); throw error; } } /** Permission only. The engine owns the response and commits or denies this * source once; normal revival quotes retain rates, prior usage and payment. * No busy gate belongs here because settlement validates its own response. */ export function fremenRevivalGrantBlock( g: FremenRevivalGrantContext, owner: string, selectedRecipient?: string, ): string | null { if (g.status !== 'playing' || g.phase !== 4 || !count(g.turn) || g.turn < 1) return 'Grant Fremen allied free revival during the current Revival phase.'; if (!id(owner) || !Array.isArray(g.players)) return 'The grant needs the current native Fremen owner.'; const owners = g.players.filter(player => player.id === owner); const fremen = owners[0]; if (owners.length !== 1 || fremen.faction !== 'fremen' || g.players.filter(player => player.faction === 'fremen').length !== 1) return 'The grant needs the current native Fremen owner.'; const group = groupRecipients(g, owner); const recipientId = group ? selectedRecipient : fremen.ally; const recipients = g.players.filter(player => player.id === recipientId); const recipient = recipients[0]; if (!id(recipientId) || recipients.length !== 1 || recipient.id === owner || (group ? !group.includes(recipientId) : recipient.ally !== owner || (selectedRecipient !== undefined && selectedRecipient !== fremen.ally))) return 'The grant needs the Fremen owner’s current mutual ally.'; if (!Array.isArray(g.freeRevival)) return 'The current Fremen revival permissions are invalid.'; if (g.freeRevival.includes(recipient.id)) return 'Fremen allied free revival has already been granted this phase.'; if (g.revivalRules?.fremenGrantBlocked !== undefined && (!Array.isArray(g.revivalRules.fremenGrantBlocked) || g.revivalRules.fremenGrantBlocked.some(target => !id(target)))) return 'The current Fremen grant prevention record is invalid.'; if (g.revivalRules?.fremenGrantBlocked?.includes(recipient.id)) return 'Karama prevented this Fremen allied grant for the current Revival phase.'; if (g.recruits?.turn === g.turn && (!count(recipient.revived) || (recipient.freeForcesRevived === undefined ? recipient.revived > 0 : !count(recipient.freeForcesRevived) || recipient.freeForcesRevived > recipient.revived))) return 'The ally’s prior normal revival usage is inconsistent or unknown; this grant cannot safely change its rate.'; return null; } export function quoteFremenRevivalGrant( g: FremenRevivalGrantContext, owner: string, recipient?: string, ): FremenRevivalGrantSource { const blocked = fremenRevivalGrantBlock(g, owner, recipient); if (blocked) throw new FremenRevivalGrantError(blocked); const fremen = g.players.find(player => player.id === owner)!; return { turn: g.turn, owner, recipient: groupRecipients(g, owner) ? recipient! : fremen.ally! }; } /** Bind the original relationship and turn before either response outcome. * No source supplied by a client may retarget a pending grant. */ export function validateFremenRevivalGrant( g: FremenRevivalGrantContext, source: FremenRevivalGrantSource, ): void { if (!source || typeof source !== 'object' || Array.isArray(source) || Object.keys(source).sort().join(',') !== 'owner,recipient,turn' || !count(source.turn) || source.turn !== g.turn || !id(source.owner) || !id(source.recipient)) throw new FremenRevivalGrantError('The Fremen allied revival grant has lost its original source.'); const current = quoteFremenRevivalGrant(g, source.owner, source.recipient); if (current.recipient !== source.recipient) throw new FremenRevivalGrantError('The Fremen allied revival grant no longer matches its original ally.'); } /** Separate owner-only projection avoids changing the established pair API. */ export function projectFremenGroupRevivalGrant( g: FremenRevivalGrantContext, viewer: string, ): Array<{recipient: string; granted: boolean; blocked: string | null}> | null { if (!id(viewer) || !Array.isArray(g.players) || g.players.filter(player => player.id === viewer && player.faction === 'fremen').length !== 1) return null; const recipients = groupRecipients(g, viewer); if (!recipients) return null; return recipients.map(recipient => ({recipient, granted: Array.isArray(g.freeRevival) && g.freeRevival.includes(recipient), blocked: fremenRevivalGrantBlock(g, viewer, recipient)})); } /** Owner-only status. No wallet, Tanks, prior counts or quoted free amount is * projected; the existing normal revival panel owns numerical allowances. */ export function projectFremenRevivalGrant( g: FremenRevivalGrantContext, viewer: string, ): { recipient: string; granted: boolean; blocked: string | null } | null { if (!id(viewer) || !Array.isArray(g.players)) return null; const owners = g.players.filter(player => player.id === viewer); const owner = owners[0]; if (owners.length !== 1 || owner.faction !== 'fremen' || !id(owner.ally) || owner.ally === owner.id || g.players.filter(player => player.id === owner.ally).length !== 1) return null; return { recipient: owner.ally, granted: Array.isArray(g.freeRevival) && g.freeRevival.includes(owner.ally), blocked: fremenRevivalGrantBlock(g, viewer), }; }