Files
Dune/game/card-availability.ts

158 lines
5.8 KiB
TypeScript
Raw Permalink 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 { familyAtomicsBlock } from './family-atomics';
import type { NoFieldDeployment } from './richese-no-field';
import { harvesterAvailable, type Card } from './cards';
import type { FactionId } from './catalog';
import { choamSaleGholaTiming } from './choam-market-ghola';
import { revivalPrevented } from './revival';
export type OrdinaryCardEffect =
| 'weather'
| 'atomics'
| 'hajr'
| 'harvester'
| 'ghola';
export type OrdinaryCardPlayer = {
id: string;
faction: FactionId;
forces: Record<string, number>;
advisors?: Record<string, { lockedTurn?: number }>;
/** Public effective presence only; no token identity, denomination or reserves. */
noField?: { deployed: Pick<NoFieldDeployment, 'controller' | 'location'> | null } | null;
hand?: readonly Card[];
movesAllowed?: number;
moved?: number;
hajrUsed?: boolean;
hajrPlays?: number;
hajrAvailable?: boolean;
};
/** Only public state and the current viewer's own hand are inspected. */
export type OrdinaryCardState = {
status: 'lobby' | 'setup' | 'playing' | 'finished';
phase: number;
advanced?: boolean;
turn?: number;
revivalPrevention?: { player: string; turn: number } | null;
active: string | null;
me: string;
nexusGuildCunning?: { active: { hajrAvailable: boolean } | null } | null;
truthtrance?: { stage: string } | null;
phaseOpening?: { passed: readonly string[] } | null;
response?: object | null;
decision?: object | null;
choamMarket?: { owner: string } | null;
shieldWallDestroyed: boolean;
stormCardWindowOpen?: boolean;
storm?: number;
stormPending?: number | null;
stormResolution?: unknown;
spiceWindow?: {
harvested: boolean;
harvesters?: number;
harvesterClosed?: boolean;
} | null;
players: readonly OrdinaryCardPlayer[];
};
export type OrdinaryCardSelection = { amount: unknown };
export type OrdinaryCardAvailability =
| { available: true }
| { available: false; reason: string };
/**
* Pure ordinary-card validation: no actions, mutations, random draws, or hidden
* opponent data. A null result leaves other card effects to their own validators.
* Omit selection for a timing-only preview; pass it to validate Weather's dial.
* This describes an ordinary {type:'card',card,...} activation, without a mode.
*/
export function ordinaryCardAvailability(
state: OrdinaryCardState,
cardId: string,
selection?: OrdinaryCardSelection,
): OrdinaryCardAvailability | null {
const blocked = (reason: string): OrdinaryCardAvailability => ({
available: false,
reason,
});
const own = state.players.find((p) => p.id === state.me);
if (!own) return blocked('You are not seated at this table.');
const card = own.hand?.find((c) => c.id === cardId);
if (
card &&
!['weather', 'atomics', 'hajr', 'harvester', 'ghola'].includes(
card.effect ?? '',
)
)
return null;
// Match the enclosing ordinary-card dispatcher gates in their existing order.
if (state.status === 'finished') return blocked('This game has ended.');
if (state.truthtrance)
return blocked(
state.truthtrance.stage === 'priority'
? 'Declare Truthtrance or pass its priority window first.'
: 'Waiting for the questioned player to answer Truthtrance.',
);
if (state.phaseOpening) {
if (state.status !== 'playing')
return blocked('Phase opening requires an active game.');
if (state.phaseOpening.passed.includes(state.me))
return blocked('You have already passed the phase opening.');
return blocked(
'Play Amal or pass the phase opening before taking other actions.',
);
}
if (state.response && !(card?.effect === 'ghola' && choamSaleGholaTiming(state)))
return blocked('Resolve the Karama response window first.');
if (state.decision)
return blocked('Waiting for the player with the pending decision.');
if (state.status === 'lobby')
return blocked('That action is not available in the lobby.');
if (state.status === 'setup')
return blocked('Complete the starting choices.');
if (!card || card.kind !== 'special')
return blocked('Select a special card in your hand.');
switch (card.effect as OrdinaryCardEffect) {
case 'ghola':
if (revivalPrevented({ phase: state.phase, turn: state.turn ?? 0,
revivalPrevention: state.revivalPrevention ?? undefined }, own.id))
return blocked('Tleilaxu prevented this player’s revival, including Ghola, for the remaining Revival Phase.');
break;
case 'hajr':
if (
state.phase !== 5 ||
state.active !== state.me ||
(state.nexusGuildCunning?.active ? !state.nexusGuildCunning.active.hajrAvailable :
own.hajrAvailable !== undefined ? !own.hajrAvailable :
own.hajrUsed === undefined ? own.movesAllowed !== 1 :
own.hajrUsed || (own.moved ?? 0) > (own.movesAllowed ?? 1))
)
return blocked('Play a held Hajr during your movement; Advanced requires a fresh immediate opportunity after a completed Force Movement.');
break;
case 'harvester':
if (state.phase !== 1 || !harvesterAvailable(state.spiceWindow))
return blocked(
'Play Harvester immediately after a spice blow, before it is accepted.',
);
break;
case 'weather':
if (state.phase !== 0 || state.stormCardWindowOpen === false || state.stormResolution)
return blocked('Play Weather Control before the storm moves.');
if (
selection &&
(typeof selection.amount !== 'number' ||
!Number.isSafeInteger(selection.amount) ||
selection.amount < 0 ||
selection.amount > 10)
)
return blocked('Storm movement must be an integer from 0 to 10.');
break;
case 'atomics':
{
const reason = familyAtomicsBlock(state, own);
if (reason) return blocked(reason);
}
break;
}
return { available: true };
}