From 850227c26d252c5b9679a808337bd2fa2c3f19cf Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:04:03 +1100 Subject: [PATCH] Prototype Richese No-Fields on foreign Homeworlds --- components/game-table.tsx | 31 +-- components/homeworld-shipment.tsx | 56 +++++ components/homeworld-table.tsx | 9 + components/mobile-stronghold.tsx | 8 +- components/ornithopter-movement.tsx | 3 +- components/planetologist-movement-action.tsx | 4 +- components/richese-no-field.tsx | 18 +- docs/CURRENT_STATUS.md | 15 ++ docs/RICHESE_NO_FIELD_RULES.md | 33 ++- docs/RULE_DECISIONS.md | 10 + game/basic-tupile-occupation.ts | 18 +- game/board-resolution-quote.ts | 3 +- game/bot-arrival.ts | 3 +- game/bot-guild-no-field-transport.ts | 7 +- game/bot-planetologist-flight.ts | 5 +- game/bots.ts | 20 +- game/combat-location.ts | 9 +- game/disaster-preflight.ts | 4 +- game/discovery-entry.ts | 4 +- game/discovery-flight-options.ts | 3 +- game/engine.ts | 198 ++++++++++++++---- game/family-atomics.ts | 8 +- game/force-inventory.ts | 13 +- game/force-presence.ts | 11 +- game/fremen-ambassador.ts | 5 +- game/guild-ambassador-no-field.ts | 13 +- game/guild-ambassador.ts | 3 +- game/homeworld-alliance.ts | 18 +- game/homeworld-ecaz-duke.ts | 8 +- game/homeworld-game.ts | 3 +- game/homeworld-occupation-history.ts | 93 ++++++-- game/homeworld-occupied-tupile.ts | 15 +- game/homeworld-shipment-options.ts | 78 +++++++ game/homeworld-stable-occupation.ts | 56 +++-- game/mobile-stronghold-collection.ts | 5 +- game/moritani-atomics.ts | 4 +- game/movement-promise-search.ts | 5 +- game/nexus-advisors.ts | 3 +- game/nexus-ecaz-betrayal.ts | 7 +- game/nexus-guild-secret-ally-options.ts | 3 +- game/reference.ts | 3 +- game/richese-homeworld-marker-combat.ts | 77 +++++++ game/richese-homeworld-marker-shipment.ts | 68 ++++++ game/richese-no-field.ts | 27 ++- game/sandmaster-movement.ts | 3 +- game/stronghold-cards.ts | 3 +- game/transport-quote.ts | 5 +- game/victory-quote.ts | 3 +- tests/battle-resolution-quote.test.ts | 3 +- ...ixture-discovery-paired-nexus-e2-skills.ts | 1 + tests/fixture-guild-no-field-transport.ts | 4 +- tests/fremen-ambassador-bots.test.ts | 1 + tests/homeworld-mobility-engine.test.ts | 1 + ...mobile-stronghold-homeworld-marker.test.ts | 19 ++ tests/nexus-ecaz-betrayal-quotes.test.ts | 3 +- tests/nexus-fremen-protection.test.ts | 1 + .../richese-homeworld-marker-alliance.test.ts | 66 ++++++ tests/richese-homeworld-marker-board.test.ts | 58 +++++ tests/richese-homeworld-marker-combat.test.ts | 86 ++++++++ ...richese-homeworld-marker-controls.test.tsx | 153 ++++++++++++++ tests/richese-homeworld-marker-http.test.ts | 88 ++++++++ tests/richese-homeworld-marker-model.test.ts | 73 +++++++ ...ichese-homeworld-marker-occupation.test.ts | 98 +++++++++ .../richese-homeworld-marker-runtime.test.ts | 174 +++++++++++++++ .../richese-homeworld-marker-shipment.test.ts | 84 ++++++++ tests/richese-homeworld-marker-table.test.tsx | 64 ++++++ tests/richese-no-field-bots.test.ts | 1 + tests/richese-no-field-recovery.test.ts | 1 + tests/richese-no-field-review.test.ts | 3 + tests/richese-no-field.test.ts | 6 + tests/truth-movement-no-field-engine.test.ts | 4 +- 71 files changed, 1806 insertions(+), 187 deletions(-) create mode 100644 game/richese-homeworld-marker-combat.ts create mode 100644 game/richese-homeworld-marker-shipment.ts create mode 100644 tests/mobile-stronghold-homeworld-marker.test.ts create mode 100644 tests/richese-homeworld-marker-alliance.test.ts create mode 100644 tests/richese-homeworld-marker-board.test.ts create mode 100644 tests/richese-homeworld-marker-combat.test.ts create mode 100644 tests/richese-homeworld-marker-controls.test.tsx create mode 100644 tests/richese-homeworld-marker-http.test.ts create mode 100644 tests/richese-homeworld-marker-model.test.ts create mode 100644 tests/richese-homeworld-marker-occupation.test.ts create mode 100644 tests/richese-homeworld-marker-runtime.test.ts create mode 100644 tests/richese-homeworld-marker-shipment.test.ts create mode 100644 tests/richese-homeworld-marker-table.test.tsx diff --git a/components/game-table.tsx b/components/game-table.tsx index bcb9ce20..46d73c71 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -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({ )), )} - {g.players - .filter( - (p) => p.noField?.deployed?.location.territory === selected, - ) - .map((p) => ( - - {faction(p.faction).name}: concealed No-Field in sector{' '} - {p.noField!.deployed!.location.sector} · counts as one force - - ))} + {g.players.flatMap((p) => { + const marker = p.noField?.deployed; + if (!marker || !isBoardNoFieldLocation(marker.location) || marker.location.territory !== selected) + return []; + return [ + {faction(p.faction).name}: concealed No-Field in sector{' '} + {marker.location.sector} · counts as one force + ]; + })} {g.status !== 'lobby' && (
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
+ Ship a concealed No-Field +

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.

+ + + {me.ally && } + + One marker · one spice total · you pay {quote.ownPayment}, ally pays {quote.allyPayment}. + {quote.blocked && ` ${quote.blocked}`} + + +
; +} + export function HomeworldShipment({ game: g, act, @@ -265,6 +320,7 @@ export function HomeworldShipment({ Ship to {homeworldName(g, destination)} + ); } diff --git a/components/homeworld-table.tsx b/components/homeworld-table.tsx index 1aa00556..4e431233 100644 --- a/components/homeworld-table.tsx +++ b/components/homeworld-table.tsx @@ -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 }) { : ''}

))} + {g.players.flatMap(player => { + const marker = player.noField?.deployed; + if (!marker || !isHomeworldNoFieldLocation(marker.location) || marker.location.homeworld !== world.id) + return []; + return [

+ {player.name}: concealed No-Field · counts as one force +

]; + })}
{card.name} card diff --git a/components/mobile-stronghold.tsx b/components/mobile-stronghold.tsx index 4a927404..297cf8ce 100644 --- a/components/mobile-stronghold.tsx +++ b/components/mobile-stronghold.tsx @@ -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 = {}; - 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); diff --git a/components/ornithopter-movement.tsx b/components/ornithopter-movement.tsx index cc3b94f7..a0c99f62 100644 --- a/components/ornithopter-movement.tsx +++ b/components/ornithopter-movement.tsx @@ -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 : '', diff --git a/components/planetologist-movement-action.tsx b/components/planetologist-movement-action.tsx index b213f2d9..75467add 100644 --- a/components/planetologist-movement-action.tsx +++ b/components/planetologist-movement-action.tsx @@ -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 || diff --git a/components/richese-no-field.tsx b/components/richese-no-field.tsx index a91a95c4..9379915e 100644 --- a/components/richese-no-field.tsx +++ b/components/richese-no-field.tsx @@ -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({ No-Field {token.value} {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({ {deployed && ( <> -

+ {isBoardNoFieldLocation(deployed.location) ?

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. -

+

:

+ Concealed at {deployedHomeworldName}. + The marker counts as one force until it is revealed. +

}