Files
Dune/game/card-availability.ts
T
Quintine b82441c834 Allow replay of the recovered physical Hajr
Count one movement grant per accepted Hajr play after original paid Box recovery. Preserve physical card custody, Basic timing and fresh Advanced post-movement opportunities. Carry unused counts through Guild Cunning with counted declarations and signed final history while retaining legacy receipt compatibility.

Validation: frozen12-file102/102, types/lint/build and two original-action/rendered-table programmes; four minimal policies retain third moves in both modes. Real cache and normal purchases, paid shipment and Box2 recovery reuse the single original Hajr. Replay is an explicit source interpretation. Native Cunning replay has pure/related evidence; wider combinations, final assurance and deployed acceptance remain open. No saved-game, reset, restart or deployment operation.
2026-10-18 12:33:50 +11:00

149 lines
5.3 KiB
TypeScript

import { fighterCount } from './advisors';
import { TERRITORIES } from './board';
import { harvesterAvailable, type Card } from './cards';
import type { FactionId } from './catalog';
import { choamSaleGholaTiming } from './choam-market-ghola';
export type OrdinaryCardEffect =
| 'weather'
| 'atomics'
| 'hajr'
| 'harvester'
| 'ghola';
export type OrdinaryCardPlayer = {
id: string;
faction: FactionId;
forces: Record<string, number>;
advisors?: Record<string, { lockedTurn?: number }>;
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;
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;
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 '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)
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':
if (state.shieldWallDestroyed || state.phase !== 0)
return blocked('Play Family Atomics before storm movement.');
if (
!TERRITORIES.some(
(t) =>
(t.id === 'shield_wall' || t.neighbors.includes('shield_wall')) &&
fighterCount(own, t.id) > 0,
)
)
return blocked('You need forces on or adjacent to the Shield Wall.');
break;
}
return { available: true };
}