Files
Dune/game/fremen-revival-grant.ts
T
Quintine d8d2e7207b
Verify and publish checkpoint container / container (push) Canceled after 0s
Support staged group Fremen revival grants
2026-10-28 23:37:46 +11:00

151 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Pick<Game, 'coalitionState' | 'advanced' | 'expansions' | 'homeworlds' |
'nexusCards' | 'leaderSkills' | 'techTokens' | 'strongholdCards' | 'ecazTreachery' | 'discoveryEnabled'>>;
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?.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),
};
}