'use client'; import { useId, useState } from 'react'; import type { Action, GameView } from '@/game/engine'; import { faction } from '@/game/catalog'; import { territory, MOBILE_STRONGHOLD } from '@/game/board'; import { fighterCount } from '@/game/advisors'; import { reserveShipmentCost } from '@/game/shipment-price'; import type { RicheseNoFieldView } from '@/game/richese-no-field'; import { quoteSmugglerNoField } from '@/game/smuggler-no-field'; import { quoteRicheseHomeworldForces, quoteRicheseHomeworldPair } from '@/game/richese-homeworld-shipment'; import { RicheseHomeworldForces } from './richese-homeworld-forces'; import { ShipmentQuote } from './shipment-quote'; import { Button } from './ui/button'; export function SmugglerNoFieldChoice({ quote, use, busy, onChange, }: { quote: ReturnType; use: boolean; busy: boolean; onChange: (use: boolean) => void; }) { if (!quote) return null; return (

The companion force enters the empty territory immediately. The No-Field token still uses the normal one-force shipment price, and its hidden forces remain in reserves until revelation.

); } /** This renderer receives public player data only, never the private token model. */ export function NoFieldBoardMarkers({ players, }: { players: readonly { id: string; name: string; faction: string; noField?: RicheseNoFieldView | null; }[]; }) { return players.flatMap((player) => { const marker = player.noField?.deployed; if (!marker) return []; const t = territory(marker.location.territory); const label = `${player.name}: concealed No-Field in ${t.name}, sector ${marker.location.sector}. Counts as one force until revealed.`; return [ {label} NF , ]; }); } export function RicheseNoFieldControls({ game, act, busy, destination, sector, allyPayment, }: { game: GameView; act: (action: Action) => void; busy: boolean; destination: string; sector: number; allyPayment: string; }) { const id = useId(); const [selection, setSelection] = useState(''); const [pairConcealedSelection, setPairConcealedSelection] = useState(''); const [pairRevealedSelection, setPairRevealedSelection] = useState(''); const [useSmuggler, setUseSmuggler] = useState(false); const [reserveForces, setReserveForces] = useState(0); const info = game.richeseNoField; const me = game.players.find((player) => player.id === game.me); if (!info || info.owner !== game.me || !info.private || !me) return null; const state = info.private; const available = state.tokens.filter( (token) => token.id !== state.lastShipped, ); const selected = available.find((token) => token.id === selection) ?? available[0]; const pairConcealed = available.find((token) => token.id === pairConcealedSelection) ?? available[0]; const pairRevealed = available.find( (token) => token.id === pairRevealedSelection && token.id !== pairConcealed?.id, ) ?? available.find((token) => token.id !== pairConcealed?.id); const nexus = game.nexusRicheseCunning; const deployed = state.deployed; const selectedTerritory = territory(destination); const rate = { faction: me.faction, halfRate: (!game.guildRateCanceled && (me.faction === 'guild' || game.players.some( (player) => player.faction === 'guild' && player.id === me.ally, ))) || game.karamaShipping?.player === me.id, }; const smuggler = info.canShip ? quoteSmugglerNoField(game, me.id, destination) : null; let group: ReturnType | null = null; let groupProblem: string | null = null; try { group = quoteRicheseHomeworldForces({ enabled: info.homeworldForcesEnabled, reserves: me.reserves, amount: reserveForces, smuggler: useSmuggler && !!smuggler }); } catch (error) { groupProblem = error instanceof Error ? error.message : 'Choose available reserve forces.'; } const cost = reserveShipmentCost(rate, selectedTerritory.type, group?.priced ?? 1); const share = !me.ally || allyPayment === '' ? Math.max(0, cost - (me.spice ?? 0)) : Number(allyPayment); const validShare = Number.isSafeInteger(share) && share >= 0 && share <= cost; const commonProblems: string[] = []; if (!info.canShip) commonProblems.push(info.shipBlock ?? 'No-Field shipment is not available now.'); if (!selected) commonProblems.push('No different No-Field token is available.'); if (destination === MOBILE_STRONGHOLD) commonProblems.push('You cannot ship directly into the mobile stronghold.'); if ( selectedTerritory.type === 'stronghold' && game.players.filter( (player) => player.id !== me.id && fighterCount(player, destination) > 0, ).length >= 2 ) commonProblems.push('Two other factions already occupy this stronghold.'); if (!selectedTerritory.sectors.includes(sector)) commonProblems.push('Select a sector in the destination territory.'); if (sector === game.storm) commonProblems.push('You cannot ship into the storm sector.'); const problems = [...commonProblems]; if (groupProblem) problems.push(groupProblem); if (!validShare) problems.push(`Choose an ally payment from 0 to ${cost}.`); else { if (share > game.aid.available) problems.push( `Only ${game.aid.available} pledged ally spice is available.`, ); if (cost - share > (me.spice ?? 0)) problems.push('Your spice does not cover the chosen payment.'); } const pairProblems = [...commonProblems]; let pairGroup: ReturnType | null = null; try { pairGroup = quoteRicheseHomeworldPair({ enabled: info.homeworldForcesEnabled, reserves: me.reserves, amount: reserveForces, revealedValue: pairRevealed?.value ?? 0 }); } catch (error) { pairProblems.push(error instanceof Error ? error.message : 'Choose available paid reserve forces.'); } const pairPurchasedRate = game.karamaShipping?.player === me.id; const pairCost = reserveShipmentCost({ faction: me.faction, halfRate: pairPurchasedRate }, selectedTerritory.type, pairGroup?.priced ?? 1); if (pairCost > (me.spice ?? 0)) pairProblems.push(`This Cunning group needs ${pairCost} own spice.`); if (nexus?.blocked) pairProblems.push(nexus.blocked); if (!pairConcealed || !pairRevealed) pairProblems.push('Choose two different available No-Field tokens.'); const showShipment = game.phase === 5 && game.active === me.id && !me.shipped; return (
Your No-Field tokens

Your token values are private until revealed. A concealed marker counts as one force, even when its value is zero. Its hidden forces remain in reserves until revelation; a Smuggler companion is a separate real force that leaves reserves immediately.

    {state.tokens.map((token) => (
  • No-Field {token.value} {deployed?.tokenId === token.id ? 'Concealed on the board' : state.lastShipped === token.id ? 'Last used · cannot repeat' : 'Available'}
  • ))}
{deployed && ( <>

Concealed at {territory(deployed.location.territory).name}, sector{' '} {deployed.location.sector}. Use the movement controls to move the marker alone or with physical forces from that territory.

{!info.canReveal && (

{info.revealBlock ?? 'Voluntary revelation is not available now.'}

)} )} {showShipment && ( <>

Destination: {selectedTerritory.name}, sector {sector}. Choose another destination on the map or in the shipment controls. This replaces your normal shipment. The marker is priced as one force; selected paid reserve forces add to that invoice.

{(game.homeworlds || reserveForces > 0) && } {nexus && (

Nexus Cunning: ship two No-Fields

Choose one token to reveal immediately and one to keep concealed at {selectedTerritory.name}, sector {sector}. The selected paid group reserves {reserveForces} physical counters first. The faceup token then brings {pairGroup?.revealed ?? 0} {' '}from the remaining reserves; the concealed marker still counts as one force. Both tokens are priced as one, plus the paid group: {pairCost} spice. The paid selection above also applies to this pair. Smuggler is a separate selection for the ordinary single-marker route.

{pairPurchasedRate &&

Your purchased Karama rate halves this combined invoice, rounded up once. Pay the Spice Bank. The original rate remains available if the No-Field counter prevents this declaration.

} {pairProblems.length > 0 && (
    {pairProblems.map((reason) => (
  • {reason}
  • ))}
)}
)} )}
); }