Files
Dune/game/family-atomics.ts

65 lines
3.8 KiB
TypeScript

import { fighterCount, isAdvisor } from './advisors';
import { splitLocation, TERRITORIES } from './board';
import type { OrdinaryCardPlayer } from './card-availability';
import { presenceByLocation } from './force-presence';
import { isBoardNoFieldLocation } from './richese-no-field';
export type FamilyAtomicsState = {
advanced?: boolean;
turn?: number;
phase: number;
shieldWallDestroyed: boolean;
storm?: number;
stormPending?: number | null;
stormCardWindowOpen?: boolean;
stormResolution?: unknown;
};
export type FamilyAtomicsPlayer = Pick<OrdinaryCardPlayer, 'id' | 'faction' | 'forces' | 'advisors' | 'noField'>;
const wall = TERRITORIES.find(territory => territory.id === 'shield_wall')!;
const nearby = TERRITORIES.filter(territory => territory.id === wall.id || territory.neighbors.includes(wall.id));
/** Supplied Advanced physical39/printed37 changes timing and the sector test.
* Basic keeps the original territory-level fighter quote. No card custody,
* modal priority or force destruction is decided by this helper.
*/
export function familyAtomicsBlock(state: FamilyAtomicsState, player: FamilyAtomicsPlayer): string | null {
if (state.shieldWallDestroyed || state.phase !== 0 || state.stormCardWindowOpen === false || state.stormResolution)
return 'Play Family Atomics before storm movement.';
// A concealed No-Field is one force for all effects (supplied physical36 B),
// including denomination0. Its public location suffices; never read private
// value, reserve capacity or token inventory merely to qualify this card.
const marker = player.noField?.deployed;
const markerLocation = marker && isBoardNoFieldLocation(marker.location) ? marker.location : null;
const markerHere = marker?.controller === player.id && markerLocation && nearby.some(territory =>
territory.id === markerLocation.territory && territory.sectors.includes(markerLocation.sector));
const forces = presenceByLocation({ forces: player.forces,
noField: markerHere ? { deployed: { location: markerLocation! } } : null });
const effective = { faction: player.faction, advisors: player.advisors, forces };
if (!state.advanced)
return nearby.some(territory => fighterCount(effective, territory.id) > 0)
? null : 'You need forces on or adjacent to the Shield Wall.';
if (!Number.isSafeInteger(state.turn) || (state.turn ?? 0) <= 1)
return 'Play Family Atomics after the first game turn.';
if (!Number.isSafeInteger(state.stormPending) || (state.stormPending ?? -1) < 0 || (state.stormPending ?? 41) > 40)
return 'Wait until storm movement is calculated before playing Family Atomics.';
// Zero is tolerated as an inactive/legacy position, not a physical board sector.
if (!Number.isSafeInteger(state.storm) || (state.storm ?? -1) < 0 || (state.storm ?? 19) > 18)
return 'Family Atomics needs the current storm position.';
const storm = state.storm!;
const qualified = Object.entries(forces).some(([key, amount]) => {
if (!Number.isSafeInteger(amount) || amount <= 0) return false;
const source = splitLocation(key);
const territory = nearby.find(candidate => candidate.id === source.territory);
if (!territory || !territory.sectors.includes(source.sector) ||
key !== `${source.territory}:${source.sector}` || isAdvisor(player, territory.id)) return false;
// Adjacent regions touch the Wall in their shared sector. Use the direct
// interval inside that region, never a circular detour around the storm.
return wall.sectors.filter(sector => territory.sectors.includes(sector)).some(touching => {
const first = Math.min(source.sector, touching), last = Math.max(source.sector, touching);
return !(storm > first && storm < last);
});
});
return qualified ? null : 'You need fighters on or adjacent to the Shield Wall with no intervening storm.';
}