193 lines
9.8 KiB
TypeScript
193 lines
9.8 KiB
TypeScript
import type { Game, Player } from './engine';
|
|
import {
|
|
eliteRevivalRemaining,
|
|
forceRevivalRemaining,
|
|
normalRevivalAllowance,
|
|
revivalPrevented,
|
|
} from './revival';
|
|
|
|
export const FREMEN_NEXUS_FREE_FORCES = 3;
|
|
const POLICY = 'ordinary-free-quota-three' as const;
|
|
type RevivalPools = {
|
|
reserves: number; tanks: number; eliteReserves: number; eliteTanks: number;
|
|
eliteRevived: number; revived: number; freeForcesRevived: number; spice: number;
|
|
};
|
|
type LegacyReturn = { elite: number; before: RevivalPools; after: RevivalPools };
|
|
export type FremenNexusRevival = {
|
|
version: 2;
|
|
event: string;
|
|
owner: string;
|
|
turn: number;
|
|
phase: 4;
|
|
faction: Player['faction'];
|
|
advanced: boolean;
|
|
revivedAtUse: number;
|
|
freeAtUse: number;
|
|
policy: typeof POLICY;
|
|
legacyReturn?: LegacyReturn;
|
|
signature: string;
|
|
};
|
|
export const fremenNexusRevivalEvent = (turn: number, owner: string) =>
|
|
JSON.stringify(['nexusFremenSecretRevival', turn, owner]);
|
|
export const fremenNexusRevivalSignature = (record: FremenNexusRevival) =>
|
|
JSON.stringify({ ...record, signature: undefined });
|
|
|
|
const count = (value: unknown): value is number =>
|
|
Number.isSafeInteger(value) && (value as number) >= 0;
|
|
function plain(value: unknown): value is Record<string, unknown> {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
const prototype: unknown = Object.getPrototypeOf(value);
|
|
return prototype === Object.prototype || prototype === null;
|
|
}
|
|
function shape(value: unknown, keys: string): value is Record<string, unknown> {
|
|
return plain(value) && Object.keys(value).sort().join(',') === keys;
|
|
}
|
|
function freeUsed(p: Player): number | null {
|
|
if (!count(p.revived)) return null;
|
|
if (p.freeForcesRevived === undefined) return p.revived === 0 ? 0 : null;
|
|
return count(p.freeForcesRevived) && p.freeForcesRevived <= p.revived
|
|
? p.freeForcesRevived : null;
|
|
}
|
|
|
|
/** Spending enables a phase-bound ordinary quota; it never returns a force,
|
|
* raises the ordinary total limit, or doubles the fixed quota with Recruits. */
|
|
export function fremenNexusRevivalOffer(g: Game, owner: string) {
|
|
const p = g.players.find(seat => seat.id === owner);
|
|
if (!p || !g.nexusCards?.cards ||
|
|
g.nexusCards.cards.hands[owner] !== 'fremen' ||
|
|
g.players.some(seat => seat.faction === 'fremen')) return null;
|
|
const used = freeUsed(p);
|
|
let blocked: string | null = null;
|
|
if (p.ally) blocked = 'An allied player cannot use a Nexus card.';
|
|
else if (g.status !== 'playing' || g.phase !== 4)
|
|
blocked = 'Use Fremen Secret Ally during Revival.';
|
|
else if (g.response || g.decision || g.truthtrance || g.phaseOpening || g.pendingRevival ||
|
|
g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy ||
|
|
g.pendingExchange || g.pendingRicheseGift || g.pendingAmbassador || g.pendingCapture ||
|
|
g.battle || g.nexusTraitorPending || g.nexusCards.phase?.stage === 'drawing')
|
|
blocked = 'Finish the current interaction before using Fremen Secret Ally.';
|
|
else if (g.nexusFremenRevivalEvents?.includes(fremenNexusRevivalEvent(g.turn, owner)) ||
|
|
g.nexusFremenRevivalHistory?.some(record => record.turn === g.turn) ||
|
|
(g.revivalRules?.nexusFremen?.turn === g.turn &&
|
|
g.revivalRules.nexusFremen.owners.includes(owner)))
|
|
blocked = 'Fremen Secret Ally has already been used this turn.';
|
|
else if (used === null)
|
|
blocked = 'Prior free-force revival usage is unknown; the Fremen quota cannot be granted safely.';
|
|
else if (revivalPrevented(g, owner))
|
|
blocked = 'Tleilaxu prevention blocks ordinary revival this turn.';
|
|
else if (g.revivalRules?.freeBlocked?.includes(owner))
|
|
blocked = 'Free ordinary revival is blocked for this player.';
|
|
|
|
let freeRemaining = 0, maxForces = 0, ordinaryRemaining = 0;
|
|
if (used !== null) {
|
|
const native = normalRevivalAllowance(g, p);
|
|
ordinaryRemaining = native.remaining;
|
|
const eligible = Math.max(0, p.tanks - (p.elites?.tanks ?? 0)) +
|
|
eliteRevivalRemaining(p, g.advanced);
|
|
const ordinaryForces = forceRevivalRemaining(g, p);
|
|
if (!g.revivalRules?.freeBlocked?.includes(owner)) {
|
|
freeRemaining = Math.min(ordinaryRemaining,
|
|
Math.max(0, Math.max(native.freeRate, FREMEN_NEXUS_FREE_FORCES) - used));
|
|
maxForces = Math.min(ordinaryForces, freeRemaining, eligible);
|
|
}
|
|
if (!blocked && native.freeRate >= FREMEN_NEXUS_FREE_FORCES)
|
|
blocked = 'The native free revival quota is already at least three; this card adds nothing.';
|
|
if (!blocked && maxForces <= Math.min(ordinaryForces, eligible,
|
|
Math.max(0, native.freeRate - used)))
|
|
blocked = 'Fremen Secret Ally cannot improve the available free revival quota.';
|
|
if (!blocked && maxForces < 1)
|
|
blocked = 'No eligible ordinary force return remains in Tanks.';
|
|
}
|
|
return {
|
|
event: fremenNexusRevivalEvent(g.turn, owner), blocked,
|
|
quota: FREMEN_NEXUS_FREE_FORCES, freeRemaining, maxForces, ordinaryRemaining,
|
|
policy: 'Enable a fixed ordinary free quota of three for this Revival phase, then use the normal revival controls. Prior free returns count; paid returns are not refunded. Native limits, typed-force caps, prevention and higher free rates still apply; Recruits does not double this fixed quota.',
|
|
};
|
|
}
|
|
|
|
/** Pure receipt creation: custody and permission are committed by the engine. */
|
|
export function createFremenNexusRevival(g: Game, owner: string): FremenNexusRevival {
|
|
const offer = fremenNexusRevivalOffer(g, owner);
|
|
if (!offer || offer.blocked)
|
|
throw new Error(offer?.blocked ?? 'Hold an eligible Fremen Secret Ally card.');
|
|
const p = g.players.find(seat => seat.id === owner)!;
|
|
const record: FremenNexusRevival = {
|
|
version: 2, event: offer.event, owner, turn: g.turn, phase: 4,
|
|
faction: p.faction, advanced: g.advanced,
|
|
revivedAtUse: p.revived, freeAtUse: freeUsed(p)!, policy: POLICY, signature: '',
|
|
};
|
|
record.signature = fremenNexusRevivalSignature(record);
|
|
return record;
|
|
}
|
|
|
|
function validIdentity(g: Game, record: Record<string, unknown>) {
|
|
const p = g.players.find(seat => seat.id === record.owner);
|
|
return !!p && !!g.nexusCards?.cards && typeof record.owner === 'string' &&
|
|
record.owner.trim().length > 0 &&
|
|
!['__proto__', 'constructor', 'prototype'].includes(record.owner) &&
|
|
record.phase === 4 && record.faction === p.faction && record.advanced === g.advanced &&
|
|
count(record.turn) && record.turn >= 1 && record.turn <= g.turn &&
|
|
record.event === fremenNexusRevivalEvent(record.turn, record.owner);
|
|
}
|
|
function validLegacyReturn(g: Game, owner: string, value: unknown): value is LegacyReturn {
|
|
if (!shape(value, 'after,before,elite')) return false;
|
|
const { before, after, elite } = value;
|
|
const keys = 'eliteReserves,eliteRevived,eliteTanks,freeForcesRevived,reserves,revived,spice,tanks';
|
|
if (!shape(before, keys) || !shape(after, keys) ||
|
|
!Object.values(before).every(count) || !Object.values(after).every(count) ||
|
|
!count(elite) || elite > FREMEN_NEXUS_FREE_FORCES) return false;
|
|
const b = before as RevivalPools, a = after as RevivalPools;
|
|
const p = g.players.find(seat => seat.id === owner)!;
|
|
return b.eliteReserves <= b.reserves && b.eliteTanks <= b.tanks &&
|
|
b.reserves + b.tanks <= 20 && b.revived === 0 && b.freeForcesRevived === 0 &&
|
|
b.tanks >= 3 && elite <= b.eliteTanks && 3 - elite <= b.tanks - b.eliteTanks &&
|
|
(!g.advanced || b.eliteRevived + elite <= 1) &&
|
|
(p.faction === 'emperor' || !(elite || b.eliteReserves || b.eliteTanks)) &&
|
|
JSON.stringify(a) === JSON.stringify({
|
|
...b, reserves: b.reserves + 3, tanks: b.tanks - 3,
|
|
eliteReserves: b.eliteReserves + elite, eliteTanks: b.eliteTanks - elite,
|
|
eliteRevived: b.eliteRevived + elite, revived: 3, freeForcesRevived: 3,
|
|
});
|
|
}
|
|
|
|
export function validateFremenNexusRevival(g: Game, record: FremenNexusRevival): void {
|
|
const keys = 'advanced,event,faction,freeAtUse,owner,phase,policy,revivedAtUse,signature,turn,version';
|
|
if (!(shape(record, keys) ||
|
|
shape(record, 'advanced,event,faction,freeAtUse,legacyReturn,owner,phase,policy,revivedAtUse,signature,turn,version')) ||
|
|
!validIdentity(g, record) || record.version !== 2 || record.policy !== POLICY ||
|
|
!count(record.revivedAtUse) || !count(record.freeAtUse) ||
|
|
record.freeAtUse > record.revivedAtUse ||
|
|
record.signature !== fremenNexusRevivalSignature(record) ||
|
|
(Object.hasOwn(record, 'legacyReturn') &&
|
|
(!validLegacyReturn(g, record.owner, record.legacyReturn) ||
|
|
record.revivedAtUse !== 0 || record.freeAtUse !== 0)))
|
|
throw new Error('Fremen Nexus revival lost its ordinary free-quota source grant.');
|
|
}
|
|
|
|
/** Saved exact-three receipts are adopted as source metadata only. Their
|
|
* original physical return stays evidence, never another gameplay mutation. */
|
|
export function upgradeFremenNexusRevival(g: Game, value: unknown): FremenNexusRevival {
|
|
if (plain(value) && value.version === 2) {
|
|
validateFremenNexusRevival(g, value as FremenNexusRevival);
|
|
return value as FremenNexusRevival;
|
|
}
|
|
if (!shape(value, 'advanced,after,before,elite,event,faction,owner,phase,signature,turn') ||
|
|
!validIdentity(g, value) ||
|
|
value.signature !== JSON.stringify({ ...value, signature: undefined }) ||
|
|
!validLegacyReturn(g, value.owner as string,
|
|
{ elite: value.elite, before: value.before, after: value.after }))
|
|
throw new Error('Fremen Nexus revival lost its original physical return.');
|
|
const record: FremenNexusRevival = {
|
|
version: 2, event: value.event as string, owner: value.owner as string,
|
|
turn: value.turn as number, phase: 4, faction: value.faction as Player['faction'],
|
|
advanced: value.advanced as boolean, revivedAtUse: 0, freeAtUse: 0, policy: POLICY,
|
|
legacyReturn: {
|
|
elite: value.elite as number,
|
|
before: { ...value.before as RevivalPools }, after: { ...value.after as RevivalPools },
|
|
}, signature: '',
|
|
};
|
|
record.signature = fremenNexusRevivalSignature(record);
|
|
validateFremenNexusRevival(g, record);
|
|
return record;
|
|
}
|