Prototype Richese No-Fields on foreign Homeworlds
This commit is contained in:
1 parent
dd3c199e2e
commit
850227c26d
71 files changed
+1806
-187
No files matched your search
+17
-14
@@ -226,6 +226,7 @@ import { guildTransportQuote } from '@/game/transport-quote';
|
||||
import { fremenReserveEntry, botGroundMoveAllowed, botMovementRange, groundMoveRouteDistance } from '@/game/bot-mobility';
|
||||
import { isAlliedShipmentVisit } from '@/game/allied-separation';
|
||||
import { location as boardLocation } from '@/game/board';
|
||||
import { isBoardNoFieldLocation } from '@/game/richese-no-field';
|
||||
import { fighterCount, isAdvisor } from '@/game/advisors';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
@@ -825,10 +826,13 @@ export function GameTable({
|
||||
),
|
||||
}
|
||||
: { from: source, amount, elite: eliteAmount };
|
||||
const ownNoField =
|
||||
const ownedDeployedNoField =
|
||||
g.richeseNoField?.owner === me.id
|
||||
? g.richeseNoField.private?.deployed
|
||||
: null;
|
||||
const ownNoField = ownedDeployedNoField && isBoardNoFieldLocation(ownedDeployedNoField.location)
|
||||
? { ...ownedDeployedNoField, location: ownedDeployedNoField.location }
|
||||
: null;
|
||||
const markerAtSource =
|
||||
!!ownNoField &&
|
||||
!!source &&
|
||||
@@ -1958,19 +1962,18 @@ export function GameTable({
|
||||
</span>
|
||||
)),
|
||||
)}
|
||||
{g.players
|
||||
.filter(
|
||||
(p) => p.noField?.deployed?.location.territory === selected,
|
||||
)
|
||||
.map((p) => (
|
||||
<span
|
||||
key={`no-field-detail-${p.id}`}
|
||||
style={{ color: faction(p.faction).color }}
|
||||
>
|
||||
{faction(p.faction).name}: concealed No-Field in sector{' '}
|
||||
{p.noField!.deployed!.location.sector} · counts as one force
|
||||
</span>
|
||||
))}
|
||||
{g.players.flatMap((p) => {
|
||||
const marker = p.noField?.deployed;
|
||||
if (!marker || !isBoardNoFieldLocation(marker.location) || marker.location.territory !== selected)
|
||||
return [];
|
||||
return [<span
|
||||
key={`no-field-detail-${p.id}`}
|
||||
style={{ color: faction(p.faction).color }}
|
||||
>
|
||||
{faction(p.faction).name}: concealed No-Field in sector{' '}
|
||||
{marker.location.sector} · counts as one force
|
||||
</span>];
|
||||
})}
|
||||
</div>
|
||||
{g.status !== 'lobby' && (
|
||||
<section
|
||||
|
||||
@@ -6,11 +6,66 @@ import { specialForceName } from '@/game/combat';
|
||||
import {
|
||||
homeworldName,
|
||||
homeworldShipmentChoice,
|
||||
richeseHomeworldMarkerChoice,
|
||||
type HomeworldShipmentSources,
|
||||
} from '@/game/homeworld-shipment-options';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
|
||||
/** This private control is separate from physical-source shipment: the marker
|
||||
* costs one but does not withdraw a reserve counter until it is revealed. */
|
||||
export function HomeworldNoFieldShipment({ game, act, busy }: {
|
||||
game: GameView; act: (action: Action) => void; busy: boolean;
|
||||
}) {
|
||||
const me = game.players.find(player => player.id === game.me);
|
||||
const info = game.richeseNoField;
|
||||
const [selectedDestination, setSelectedDestination] = useState('');
|
||||
const [selectedToken, setSelectedToken] = useState('');
|
||||
const [allyPayment, setAllyPayment] = useState(0);
|
||||
if (!me || me.faction !== 'richese' || info?.owner !== me.id || !info.private) return null;
|
||||
const targets = (game.homeworlds?.worlds ?? []).filter(world =>
|
||||
world.native !== me.id && world.native !== me.ally);
|
||||
const destination = targets.find(world => world.id === selectedDestination) ?? targets[0];
|
||||
const tokens = info.private.tokens.filter(token => token.id !== info.private!.lastShipped);
|
||||
const token = tokens.find(candidate => candidate.id === selectedToken) ?? tokens[0];
|
||||
const quote = richeseHomeworldMarkerChoice(game, destination?.id ?? '', token?.id ?? '', allyPayment);
|
||||
return <fieldset disabled={busy || !!me.autopilot || !!game.homeworldShipment?.blocked}
|
||||
className="space-y-3 pt-3">
|
||||
<legend>Ship a concealed No-Field</legend>
|
||||
<p className="fine">Provisional Homeworld rule: ship one concealed marker from your supply to
|
||||
another non-allied Homeworld for one spice. Its value stays private and its physical forces
|
||||
remain in your reserves until the marker is revealed. This uses your normal shipment.</p>
|
||||
<label className="block space-y-1">Destination Homeworld for No-Field
|
||||
<select aria-label="No-Field destination Homeworld" value={destination?.id ?? ''}
|
||||
onChange={event => setSelectedDestination(event.target.value)}>
|
||||
{!targets.length && <option value="">No eligible destination</option>}
|
||||
{targets.map(world => <option key={world.id} value={world.id}>
|
||||
{homeworldName(game, world.id)} · {game.players.find(player => player.id === world.native)?.name}
|
||||
</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block space-y-1">Private No-Field token
|
||||
<select aria-label="Private No-Field token" value={token?.id ?? ''}
|
||||
onChange={event => setSelectedToken(event.target.value)}>
|
||||
{!tokens.length && <option value="">No different token available</option>}
|
||||
{tokens.map(candidate => <option key={candidate.id} value={candidate.id}>No-Field {candidate.value}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{me.ally && <label className="block space-y-1">Allied spice contribution · {game.aid.available} pledged
|
||||
<Input type="number" min={0} max={Math.min(1, game.aid.available)} step={1} value={allyPayment}
|
||||
onChange={event => setAllyPayment(Number(event.target.value))} />
|
||||
</label>}
|
||||
<output className="notice block" aria-live="polite">
|
||||
One marker · one spice total · you pay {quote.ownPayment}, ally pays {quote.allyPayment}.
|
||||
{quote.blocked && ` ${quote.blocked}`}
|
||||
</output>
|
||||
<Button className="game-action" disabled={busy || !!me.autopilot || !quote.action}
|
||||
onClick={() => { if (quote.action) act(quote.action); }}>
|
||||
Ship No-Field to {homeworldName(game, destination?.id ?? '')}
|
||||
</Button>
|
||||
</fieldset>;
|
||||
}
|
||||
|
||||
export function HomeworldShipment({
|
||||
game: g,
|
||||
act,
|
||||
@@ -265,6 +320,7 @@ export function HomeworldShipment({
|
||||
Ship to {homeworldName(g, destination)}
|
||||
</Button>
|
||||
</fieldset>
|
||||
<HomeworldNoFieldShipment game={g} act={act} busy={busy} />
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { faction } from '@/game/catalog';
|
||||
import { HOMEWORLD_CARDS } from '@/game/homeworld-cards';
|
||||
import { isHomeworldNoFieldLocation } from '@/game/richese-no-field';
|
||||
import type { NativeReserveSelections } from '@/game/homeworld-native-reserves';
|
||||
import { emperorHomeworldMoveAction } from '@/game/homeworld-options';
|
||||
import type { EmperorHomeworldMove } from '@/game/homeworld-emperor-move';
|
||||
@@ -54,6 +55,14 @@ export function HomeworldTable({ game: g }: { game: GameView }) {
|
||||
: ''}
|
||||
</p>
|
||||
))}
|
||||
{g.players.flatMap(player => {
|
||||
const marker = player.noField?.deployed;
|
||||
if (!marker || !isHomeworldNoFieldLocation(marker.location) || marker.location.homeworld !== world.id)
|
||||
return [];
|
||||
return [<p key={`no-field-${player.id}`} className="text-base! leading-7!">
|
||||
{player.name}: concealed No-Field · counts as one force
|
||||
</p>];
|
||||
})}
|
||||
<details>
|
||||
<summary className="cursor-pointer py-2">
|
||||
{card.name} card
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { quoteSandmasterStronghold } from '@/game/sandmaster-movement';
|
||||
import { mobileStrongholdCollectionCapacity } from '@/game/mobile-stronghold-collection';
|
||||
import { isBoardNoFieldLocation } from '@/game/richese-no-field';
|
||||
const label = (key: string) => {
|
||||
const loc = splitLocation(key);
|
||||
return `${territory(loc.territory).name}${loc.sector ? ` · sector ${loc.sector}` : ''}`;
|
||||
@@ -56,7 +57,12 @@ export function MobileStronghold({
|
||||
return chosen === false ? [] : [typeof chosen === 'string' && keys.includes(chosen) ? chosen : keys[0]];
|
||||
}) : [];
|
||||
const remaining: Record<string, number> = {};
|
||||
const capacity = mobileStrongholdCollectionCapacity(g.players);
|
||||
const capacity = mobileStrongholdCollectionCapacity(g.players.map(player => {
|
||||
const marker = player.noField?.deployed;
|
||||
return { ...player, noField: marker && isBoardNoFieldLocation(marker.location)
|
||||
? { deployed: { controller: marker.controller, location: marker.location } }
|
||||
: null };
|
||||
}));
|
||||
const collections = route.map((key, index) => {
|
||||
const before = remaining[key] ?? Math.max(0, (g.spice[key] ?? 0) - Number(skillPiles.includes(key)));
|
||||
const maximum = Math.min(before, capacity);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type OrnithopterMode,
|
||||
} from '@/game/ornithopter';
|
||||
import { location, splitLocation, gameTerritories } from '@/game/board';
|
||||
import { isBoardNoFieldLocation } from '@/game/richese-no-field';
|
||||
import { botGroundMoveAllowed } from '@/game/bot-mobility';
|
||||
import { Button } from './ui/button';
|
||||
import { CardInspector, CardRules } from './card-inspector';
|
||||
@@ -82,7 +83,7 @@ export function OrnithopterMovement({
|
||||
me.elites?.forces ?? {},
|
||||
forces,
|
||||
elites,
|
||||
typeof move.noField === 'string' && marker
|
||||
typeof move.noField === 'string' && marker && isBoardNoFieldLocation(marker.location)
|
||||
? {
|
||||
tokenId: move.noField,
|
||||
event: typeof move.event === 'string' ? move.event : '',
|
||||
|
||||
@@ -12,6 +12,7 @@ import { botGroundMoveAllowed } from '@/game/bot-mobility';
|
||||
import { isAdvisor } from '@/game/advisors';
|
||||
import { quoteAdvisorGatherMatching, AdvisorGatherMatchingError } from '@/game/advisor-gather-matching';
|
||||
import { location, splitLocation, validGameLocation } from '@/game/board';
|
||||
import { isBoardNoFieldLocation } from '@/game/richese-no-field';
|
||||
import { nexusGuildMovementAvailable } from '@/game/nexus-guild-cunning-options';
|
||||
import { planetologistFlightSelection, type PlanetologistFlight } from '@/game/planetologist-flight';
|
||||
import { validateCohortSelection } from '@/game/ornithopter';
|
||||
@@ -30,8 +31,9 @@ export function planetologistNoFieldMarker(game: GameView) {
|
||||
const state = info.private;
|
||||
const marker = state?.deployed;
|
||||
const publicMarker = info.public.deployed;
|
||||
if (!state || !marker || marker.controller !== me.id ||
|
||||
if (!state || !marker || !isBoardNoFieldLocation(marker.location) || marker.controller !== me.id ||
|
||||
!publicMarker || publicMarker.controller !== me.id ||
|
||||
!isBoardNoFieldLocation(publicMarker.location) ||
|
||||
marker.location.territory !== publicMarker.location.territory ||
|
||||
marker.location.sector !== publicMarker.location.sector ||
|
||||
marker.tokenId !== state.lastShipped ||
|
||||
|
||||
@@ -6,7 +6,8 @@ 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 { isBoardNoFieldLocation, isHomeworldNoFieldLocation, type RicheseNoFieldView } from '@/game/richese-no-field';
|
||||
import { HOMEWORLD_CARDS } from '@/game/homeworld-cards';
|
||||
import { quoteSmugglerNoField } from '@/game/smuggler-no-field';
|
||||
import { quoteRicheseHomeworldForces, quoteRicheseHomeworldPair } from '@/game/richese-homeworld-shipment';
|
||||
import { RicheseHomeworldForces } from './richese-homeworld-forces';
|
||||
@@ -58,7 +59,7 @@ export function NoFieldBoardMarkers({
|
||||
}) {
|
||||
return players.flatMap((player) => {
|
||||
const marker = player.noField?.deployed;
|
||||
if (!marker) return [];
|
||||
if (!marker || !isBoardNoFieldLocation(marker.location)) 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 [
|
||||
@@ -135,6 +136,10 @@ export function RicheseNoFieldControls({
|
||||
) ?? available.find((token) => token.id !== pairConcealed?.id);
|
||||
const nexus = game.nexusRicheseCunning;
|
||||
const deployed = state.deployed;
|
||||
const deployedHomeworldId = deployed && isHomeworldNoFieldLocation(deployed.location)
|
||||
? deployed.location.homeworld : null;
|
||||
const deployedHomeworld = game.homeworlds?.worlds?.find(world => world.id === deployedHomeworldId);
|
||||
const deployedHomeworldName = HOMEWORLD_CARDS.find(card => card.id === deployedHomeworld?.card)?.name ?? 'a Homeworld';
|
||||
const selectedTerritory = territory(destination);
|
||||
const rate = {
|
||||
faction: me.faction,
|
||||
@@ -225,7 +230,7 @@ export function RicheseNoFieldControls({
|
||||
<strong>No-Field {token.value}</strong>
|
||||
<span className="block text-sm">
|
||||
{deployed?.tokenId === token.id
|
||||
? 'Concealed on the board'
|
||||
? 'Concealed in play'
|
||||
: state.lastShipped === token.id
|
||||
? 'Last used · cannot repeat'
|
||||
: 'Available'}
|
||||
@@ -235,11 +240,14 @@ export function RicheseNoFieldControls({
|
||||
</ul>
|
||||
{deployed && (
|
||||
<>
|
||||
<p>
|
||||
{isBoardNoFieldLocation(deployed.location) ? <p>
|
||||
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.
|
||||
</p>
|
||||
</p> : <p>
|
||||
Concealed at {deployedHomeworldName}.
|
||||
The marker counts as one force until it is revealed.
|
||||
</p>}
|
||||
<Button
|
||||
className="min-h-11 whitespace-normal"
|
||||
disabled={busy || !info.canReveal}
|
||||
|
||||
Reference in new issue
Block a user