diff --git a/.gitignore b/.gitignore index 142b8127..2fe00de8 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,11 @@ # testing /coverage +*.tsbuildinfo + +# patch/editor backups +*.orig +*.rej # next.js /.next/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..84c0a09b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,57 @@ +# Working on Dune + +Unofficial Dune board game: React 19, TypeScript, Vinext/Vite, Cloudflare +Workers and D1. Use npm and the checked-in lockfile; Node >=22.13 is required +for the in-memory SQLite tests. `tsx` is a pinned development dependency. + +## Start small + +- Read `README.md` and this guide first. See `docs/DEVELOPMENT.md` for the + architecture map and focused verification commands. +- Run `git status --short` before editing; preserve other work. +- Search the relevant feature in `game/`, `components/`, `tests/`, and `docs/`. + Use `rg -n` and bounded reads for `game/engine.ts` and + `components/game-table.tsx`; both are large orchestration files. +- `docs/IMPLEMENTATION_STATUS.md` is a historical evidence log, newest first. + Read its opening checkpoint and the relevant feature document rather than + loading the entire log. Verify historical claims against current code/tests. +- Do not read `github.md` for routine development: it is ignored private local + credential material. Do not put secrets in logs or tracked files. + +## Implement and verify + +- Keep rule calculations/validation in focused `game/` modules; integrate them + through the engine, player view, bots and UI as the feature requires. +- Preserve rejected-action immutability, physical card/force custody, private + information boundaries, and JSON save/restore behavior. Reuse shared rule + quotes instead of independently duplicating legality in bots or components. +- Add meaningful regressions to `tests/**/*.test.ts`; discovery is automatic. + HTTP tests must start with `// @dune-suite integration`. Tests importing + `node:sqlite` are classified as in-memory recovery tests automatically. +- During iteration: `npm test -- `; multiple fragments form + a union. Use `--list` to inspect selection and `--name ` to narrow + cases. A filename typo fails rather than silently passing an empty suite. +- Before handing off code changes: `npm run check` (types, lint, offline tests). + Run `npm run build` for app/build/dependency changes. Run + `npm run test:integration` against a development server for HTTP, auth or + persistence changes. Documentation-only edits need link/command review. +- Extend testing when failures or changed behavior justify it. Do not run AI + calibration or browser acceptance for unrelated maintenance changes. +- If a sandbox blocks test subprocess pipes with `EPERM`, rerun the exact check + with approved subprocess permissions. Do not disable isolation or skip tests + to obtain a green result; the runner self-test checks real failure propagation. +- Report actual check results and limitations. Passing focused tests does not + certify a complete faction, expansion or rules mode; preserve release gates. + +## Preserve development state + +- Keep `.wrangler/state` and all saved games. Never reset a database to fix a + test, migration, connection or gameplay problem. Migrations are additive. +- Reuse a running dev server. Warn before interrupting human play, defer to a + safe point, and verify restoration after a necessary restart. No recurring + maintenance automation is installed; do not recreate it without a request. +- `.openai/hosting.json` identifies the existing Sites project. Preserve it. + Follow the applicable Sites skills for site work; publication has existing + completion and verification gates documented in `README.md`. +- Keep generated builds, caches and editor backups out of version control. + Avoid mass formatting, unrelated dependency updates and broad engine rewrites. diff --git a/README.md b/README.md index 72816a36..16aaa4c2 100644 --- a/README.md +++ b/README.md @@ -30,16 +30,21 @@ An uncertain saved-kit recovery keeps its exact retry details in memory and prev ## Verify changes -Run the development server before the persisted multiplayer suite: +Use the [development guide](docs/DEVELOPMENT.md) for architecture, focused tests +and the verification workflow. Tests are discovered automatically; the default +suite includes in-memory persistence recovery and needs no server: ```sh -npm test -npm run test:multiplayer -npm run typecheck -npm run lint +npm test -- ecaz-collection ecaz-spice +npm run check npm run build ``` +Run `npm run test:integration` against a development server for HTTP/session +changes. `npm run test:multiplayer` remains available for recovery plus HTTP +checks; `npm run test:recovery` runs only in-memory persistence tests. All test +commands accept filename fragments and `--list` after `--`. + The production build is written to `dist`. `npm start` runs that build with Wrangler; apply its database migrations to the intended storage environment before use. The included local migration command targets the development database, not a remote deployment. Reproduce the current AI study with: diff --git a/components/ambassador-movement.tsx b/components/ambassador-movement.tsx index d6014fda..5eeefc9c 100644 --- a/components/ambassador-movement.tsx +++ b/components/ambassador-movement.tsx @@ -84,7 +84,10 @@ export function AmbassadorMovement({ ? 'Choose an available destination.' : !total && !includesMarker ? 'Choose physical forces or the concealed No-Field to relocate.' - : null); + : destination.maximum !== null && + total + Number(includesMarker) > destination.maximum + ? `At most ${destination.maximum} entering forces are currently supported at this destination; the concealed marker counts as one.` + : null); const chooseDestination = (key: string) => { setDestinationKey(key); setForces((old) => ({ ...old, [key]: 0 })); diff --git a/components/ambassador-shipment.tsx b/components/ambassador-shipment.tsx index 1349b4e4..4f0293db 100644 --- a/components/ambassador-shipment.tsx +++ b/components/ambassador-shipment.tsx @@ -34,7 +34,17 @@ export function AmbassadorShipment({ return null; const me = game.players.find((p) => p.id === game.me)!; const shipment = entry.shipment; - const maximum = Math.min(shipment.maximum, me.reserves); + const destination = + shipment.destinations.find( + (d) => location(d.territory, d.sector) === destinationKey, + ) ?? + shipment.destinations.find((d) => !d.blocked) ?? + shipment.destinations[0]; + const maximum = Math.min( + shipment.maximum, + me.reserves, + destination?.maximum ?? shipment.maximum, + ); const amount = maximum ? bounded(count ?? maximum, 1, maximum) : 0; const minimumElite = Math.max( 0, @@ -42,12 +52,6 @@ export function AmbassadorShipment({ ); const maximumElite = Math.min(amount, shipment.eliteReserves); const elite = bounded(eliteCount, minimumElite, maximumElite); - const destination = - shipment.destinations.find( - (d) => location(d.territory, d.sector) === destinationKey, - ) ?? - shipment.destinations.find((d) => !d.blocked) ?? - shipment.destinations[0]; const reason = !amount ? 'You have no reserve forces available for this shipment.' : !destination diff --git a/components/bidding-end.tsx b/components/bidding-end.tsx new file mode 100644 index 00000000..e751eae9 --- /dev/null +++ b/components/bidding-end.tsx @@ -0,0 +1,193 @@ +'use client'; + +import { useState } from 'react'; +import type { Action, GameView } from '@/game/engine'; +import { + biddingEndCanAct, + biddingEndDiscardChoice, +} from '@/game/bidding-end-options'; +import { Button } from '@/components/ui/button'; +import { CardInspector } from './card-inspector'; + +export function BiddingEnd({ + game, + act, + busy, +}: { + game: GameView; + act: (action: Action) => void; + busy: boolean; +}) { + if (!game.biddingEnd) return null; + return ( + + ); +} + +function BiddingEndChoice({ + game: g, + act, + busy, +}: { + game: GameView; + act: (action: Action) => void; + busy: boolean; +}) { + const [selected, setSelected] = useState([]); + const end = g.biddingEnd!; + const me = g.players.find((p) => p.id === g.me)!; + const canAct = biddingEndCanAct(g); + const heldSelection = selected.filter((id) => + me.hand?.some((card) => card.id === id), + ); + const choice = biddingEndDiscardChoice(g, heldSelection); + const market = g.choamMarket?.owner === g.me ? g.choamMarket : null; + const kaitain = end.kaitain?.owner === g.me; + const send = (action: Action) => + act({ ...action, type: 'biddingEnd', event: end.event }); + return ( +
+

End of Bidding

+

+ Optional card actions remain available until everyone below is ready. A + new action clears readiness so the table can respond to the change. +

+
    + {end.owners.map((id) => ( +
  • + {g.players.find((p) => p.id === id)?.name}:{' '} + {end.ready.includes(id) ? 'Ready' : 'Choosing'} +
  • + ))} +
+ {kaitain && ( + <> +

Kaitain paid discards

+

+ Pay 2 spice per chosen Treachery Card. Discarding a card does not + play its effect. +

+ {!end.kaitain!.eligible && ( +

+ Kaitain must have high population to use paid discards. +

+ )} +
+ {me.hand?.map((card) => ( +
+ + +
+ ))} +
+

+ {heldSelection.length} selected · {choice.cost} spice · You have{' '} + {me.spice ?? 0} spice. +

+ {choice.blocked &&

{choice.blocked}

} + + + )} + {market && ( + <> +

CHOAM card market

+

+ Sales reveal the card and give the table a Karama response. Trade + offers stay private to you and your ally. +

+ {market.worthlessSaleBlocked && ( +

{market.worthlessSaleBlocked}

+ )} + {market.sales?.map((sale) => ( + + ))} + {!market.sales?.length && ( +

No eligible sales remain this phase.

+ )} + {market.canTrade && ( + <> +

Offer your ally a card

+ {me.hand?.map((card) => ( +
+ + +
+ ))} + + )} + + )} + {end.owners.includes(g.me) && ( + + )} +
+ ); +} diff --git a/components/caladan-reinforcement.tsx b/components/caladan-reinforcement.tsx new file mode 100644 index 00000000..0326594e --- /dev/null +++ b/components/caladan-reinforcement.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { useState } from 'react'; +import type { Action, GameView } from '@/game/engine'; +import { + caladanReinforcementCanAct, + caladanReinforcementChoice, +} from '@/game/caladan-reinforcement-options'; +import { Button } from './ui/button'; + +type Props = { game: GameView; act: (action: Action) => void; busy: boolean }; + +export function CaladanReinforcement({ game, act, busy }: Props) { + if ( + !game.caladanReinforcement || + game.caladanReinforcement.player !== game.me || + game.decision?.kind !== 'caladanReinforcement' || + game.decision.player !== game.me + ) + return null; + return ( + + ); +} + +function ReinforcementChoice({ game, act, busy }: Props) { + const offer = game.caladanReinforcement!; + const [destination, setDestination] = useState( + offer.destinations.find((target) => !target.blocked)?.id ?? '', + ); + const canAct = caladanReinforcementCanAct(game); + const choice = caladanReinforcementChoice(game, destination); + return ( +
+

+ Add one reserve force at the battle location, or leave it in reserves. +

+ + {choice.blocked && ( +

+ {choice.blocked} +

+ )} +
+ + +
+
+ ); +} diff --git a/components/choam-market.tsx b/components/choam-market.tsx index a21bb796..e30d1817 100644 --- a/components/choam-market.tsx +++ b/components/choam-market.tsx @@ -76,6 +76,9 @@ export function ChoamMarket({ exact duplicate for three. A sale reveals the card and gives the table a Karama response.

+ {market.worthlessSaleBlocked && ( +

{market.worthlessSaleBlocked}

+ )} {market.sales?.map((sale) => ( + {allianceBlock && ( +

+ {allianceBlock} +

+ )}

- Alliance proposals, acquisition for an ally and Duke loans - through this Ambassador remain unfinished. + Both factions must be unallied. The entrant chooses whether to + accept. The triggered Ambassador returns to supply even if the + offer is refused. Duke loans remain unfinished.

) : ( @@ -157,6 +189,30 @@ export function EcazEntry({ Leave Ambassador in place + ) : entry.stage === 'allianceReply' ? ( + <> +

+ {game.players.find((p) => p.id === entry.owner)?.name} offers you an + alliance. Accepting activates both factions’ alliance abilities + immediately. Duke Vidal is not included in this offer. Your + remaining actions resume after your reply. +

+ + + ) : entry.stage === 'copy' ? ( <>

diff --git a/components/ecaz-poison-income.tsx b/components/ecaz-poison-income.tsx new file mode 100644 index 00000000..88049948 --- /dev/null +++ b/components/ecaz-poison-income.tsx @@ -0,0 +1,33 @@ +import type { GameView } from '@/game/engine'; +import { PHASES } from '@/game/catalog'; + +export function EcazPoisonIncome({ game: g }: { game: GameView }) { + if ( + g.players.find((player) => player.id === g.me)?.faction !== 'ecaz' || + !g.ecazPoisonIncome?.length + ) + return null; + return ( +

+

Your Ecaz poison income

+

+ Private record of spice you received when poison weapons were discarded. +

+
    + {g.ecazPoisonIncome + .slice(-6) + .reverse() + .map((income, index) => ( +
  • + Turn {income.turn} · {PHASES[income.phase]}: +{income.amount}{' '} + spice from {income.count} discarded poison{' '} + {income.count === 1 ? 'weapon' : 'weapons'}. +
  • + ))} +
+
+ ); +} diff --git a/components/face-dance-decision.tsx b/components/face-dance-decision.tsx index 57b84f77..9c672a26 100644 --- a/components/face-dance-decision.tsx +++ b/components/face-dance-decision.tsx @@ -15,30 +15,53 @@ export function FaceDanceDecision({ }) { const d = g.decision; const me = g.players.find((p) => p.id === g.me)!; + const world = + d?.kind === 'faceDance' + ? g.combatLocations?.find( + (place) => place.id === d.territory && place.kind === 'homeworld', + ) + : undefined; + const foreign = + d?.kind === 'faceDance' ? world?.forces?.[d.winner] : undefined; const maximum = d?.kind === 'faceDance' - ? Object.entries(g.players.find((p) => p.id === d.winner)!.forces) - .filter(([key]) => splitLocation(key).territory === d.territory) - .reduce((sum, [, count]) => sum + count, 0) + ? world + ? (foreign?.normal ?? 0) + (foreign?.elite ?? 0) + : Object.entries(g.players.find((p) => p.id === d.winner)!.forces) + .filter(([key]) => splitLocation(key).territory === d.territory) + .reduce((sum, [, count]) => sum + count, 0) : 0; - const [sources, setSources] = useState>({ - reserves: Math.min(me.reserves, maximum), - }); + const [sources, setSources] = useState>( + world + ? {} + : { + reserves: Math.min(me.reserves, maximum), + }, + ); const [sector, setSector] = useState( - d?.kind === 'faceDance' ? territory(d.territory).sectors[0] : 0, + d?.kind === 'faceDance' && !world ? territory(d.territory).sectors[0] : 0, ); if (d?.kind !== 'faceDance') return null; const held = me.faceDancers?.some( (c) => !c.revealed && c.leader === d.identity, ); const total = Object.values(sources).reduce((sum, n) => sum + n, 0); + const availableSources = world + ? me.forces + : { reserves: me.reserves, ...me.forces }; return ( <>

Face Dancer opportunity

{g.players.find((p) => p.id === d.winner)?.name} won in{' '} - {territory(d.territory).name}. Their battle rewards remain theirs. + {world?.name ?? territory(d.territory).name}. Their battle rewards + remain theirs.

+ {d.blocked && ( + + {d.blocked} + + )} {held ? ( <>

@@ -46,51 +69,69 @@ export function FaceDanceDecision({ up to {maximum} with your forces. Their leader goes to the tanks without a spice bounty.

- {Object.entries({ reserves: me.reserves, ...me.forces }).map( - ([key, available]) => ( - - ), + {world && ( +

+ Your native forces already at {world.name} remain there. Choose + any additional replacement forces from your positions on Arrakis, + or reveal with zero additional forces. +

+ )} + {Object.entries(availableSources).map(([key, available]) => ( + + ))} + {!world && ( + )} -

{total} / {maximum} replacement forces selected

+ + + ); +} diff --git a/components/homeworld-cards.tsx b/components/homeworld-cards.tsx new file mode 100644 index 00000000..440570d5 --- /dev/null +++ b/components/homeworld-cards.tsx @@ -0,0 +1,227 @@ +'use client'; + +import Image from 'next/image'; +import { Dialog as DialogPrimitive } from '@base-ui/react/dialog'; +import { FACTIONS } from '@/game/catalog'; +import { HOMEWORLD_CARDS, type HomeworldCard } from '@/game/homeworld-cards'; +import { Button } from './ui/button'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs'; +import { + Dialog, + DialogClose, + DialogDescription, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} from './ui/dialog'; + +function HomeworldPopulation({ + card, + side, + enlarged, +}: { + card: HomeworldCard; + side: 'high' | 'low'; + enlarged: boolean; +}) { + const face = card[side]; + return ( +
+
+

+ {side === 'high' ? 'High' : 'Low'} population +

+ + {face.reserves.min}–{face.reserves.max}{' '} + {card.reserveType === 'sardaukar' ? 'Sardaukar' : 'reserves'} + +
+ {face.gameplay.map((text) => ( +

+ {text} +

+ ))} +
+

+ + +{face.battleStrength} battle strength + +
+ Added to the native faction’s dial here. +

+

+ + {face.battleStrength} explosion losses + +
+ Native forces lost to a Lasgun/Shield explosion here. +

+
+
+ ); +} + +export function HomeworldFace({ + card, + enlarged = false, +}: { + card: HomeworldCard; + enlarged?: boolean; +}) { + const faction = FACTIONS.find((item) => item.id === card.faction)!; + return ( +
+
+
+

+ {faction.name} · Homeworld +

+

+ {card.name} +

+
+ {card.id === 'salusa_secundus' && ( + Advanced only + )} +
+ + + + + High face + + + Low / occupied face + + + + + + + +
+

Occupied

+ {card.occupied.gameplay.map((text) => ( +

+ {text} +

+ ))} +

+ + {card.occupied.spiceIcons} bank spice during Spice Collection +

+

+ The low-population penalty continues while occupied. +

+
+
+
+ {card.id === 'salusa_secundus' && ( +

+ Both printed ranges include 2. The module’s minimum-high rule makes 2 + Sardaukar high population. +

+ )} + {!enlarged && ( +
+ + + } + > + Inspect {card.name} + + + + +
+ + {card.name} + + + } + > + Close + +
+ + Both faces of the Homeworld card, including population ranges, + native battle values and occupied effects. + +
+ +
+
+
+
+
+ )} +
+ Ecaz & Moritani · 1 two-sided card + 1 world token +
+
+ ); +} + +export function HomeworldCardGallery() { + return ( +
+

+ 13 worlds · Inspect either face to read its complete gameplay effects. + Homeworld games are not yet available. +

+
+ {HOMEWORLD_CARDS.map((card) => ( + + ))} +
+
+ ); +} diff --git a/components/homeworld-revival-deployment.tsx b/components/homeworld-revival-deployment.tsx new file mode 100644 index 00000000..c68e5678 --- /dev/null +++ b/components/homeworld-revival-deployment.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { useState } from 'react'; +import type { Action, GameView } from '@/game/engine'; +import { + homeworldRevivalDeploymentCanAct, + homeworldRevivalDeploymentChoice, +} from '@/game/homeworld-revival-deployment-options'; +import { Button } from './ui/button'; + +type Props = { + game: GameView; + act: (action: Action) => void; + busy: boolean; +}; + +export function HomeworldRevivalDeployment({ game, act, busy }: Props) { + if ( + !game.homeworldRevivalDeployment || + game.homeworldRevivalDeployment.player !== game.me || + game.decision?.kind !== 'homeworldRevivalDeployment' || + game.decision.player !== game.me + ) + return null; + return ( + + ); +} + +function DeploymentChoice({ game, act, busy }: Props) { + const offer = game.homeworldRevivalDeployment!; + const [destination, setDestination] = useState( + offer.destinations.find((target) => !target.blocked)?.id ?? '', + ); + const canAct = homeworldRevivalDeploymentCanAct(game); + const choice = homeworldRevivalDeploymentChoice(game, destination); + const amount = offer.normal + offer.elite; + return ( +
+

+ {offer.kind === 'fedaykin' + ? 'Place revived Fedaykin' + : 'Place revived forces'} +

+

+ Place the entire eligible revived group in one destination, or leave it + in reserves. +

+

+ {offer.normal} ordinary · {offer.elite} starred · {amount} total +

+ + {choice.blocked && ( +

+ {choice.blocked} +

+ )} +
+ + +
+
+ ); +} diff --git a/components/homeworld-shipment.tsx b/components/homeworld-shipment.tsx new file mode 100644 index 00000000..22c153d9 --- /dev/null +++ b/components/homeworld-shipment.tsx @@ -0,0 +1,221 @@ +'use client'; + +import { useState } from 'react'; +import type { Action, GameView } from '@/game/engine'; +import { specialForceName } from '@/game/combat'; +import { + homeworldName, + homeworldShipmentChoice, + type HomeworldShipmentSources, +} from '@/game/homeworld-shipment-options'; +import { Button } from './ui/button'; +import { Input } from './ui/input'; + +export function HomeworldShipment({ + game: g, + act, + busy, +}: { + game: GameView; + act: (action: Action) => void; + busy: boolean; +}) { + const worlds = g.homeworlds?.worlds ?? []; + const me = g.players.find((player) => player.id === g.me)!; + const available = worlds.filter((world) => { + const force = world.forces[g.me]; + return force && force.normal + force.elite > 0; + }); + const targets = worlds.filter( + (world) => + (world.native !== g.me || me.faction === 'guild') && + world.native !== me.ally, + ); + const [origin, setOrigin] = useState(available[0]?.id ?? ''); + const [destination, setDestination] = useState( + targets.find((world) => world.id !== available[0]?.id)?.id ?? '', + ); + const [sources, setSources] = useState({}); + const [allyPayment, setAllyPayment] = useState(0); + const option = g.homeworldShipment; + if (!option) return null; + const imperial = g.advanced && me.faction === 'emperor'; + const selectedOrigins = available.filter((world) => + origin === 'imperial-pair' ? world.native === me.id : world.id === origin, + ); + const selected = Object.fromEntries( + selectedOrigins.flatMap((world) => { + const choice = sources[world.id]; + return choice && choice.normal + choice.elite !== 0 + ? [[world.id, choice]] + : []; + }), + ); + const quote = homeworldShipmentChoice(g, destination, selected, allyPayment); + const special = specialForceName(me.faction); + return ( +
+ + Ship to another Homeworld + +

+ Use your shipment to send forces from one Homeworld to another. Native + reserves and foreign garrisons have separate source pools.{' '} + {me.faction === 'guild' + ? 'Guild may also return a foreign garrison to Junction.' + : 'Your own Homeworld cannot be a destination.'}{' '} + Allied Homeworlds cannot be destinations. +

+

+ {me.faction === 'guild' + ? 'Guild pays one spice per two forces, rounded up.' + : 'Pay one spice per force.'}{' '} + This uses your shipment; your normal movement remains available. +

+ {option.blocked && ( + {option.blocked} + )} +
+ + {selectedOrigins.map((world) => { + const pool = world.forces[me.id]; + const chosen = sources[world.id] ?? { normal: 0, elite: 0 }; + return ( +
+ + {homeworldName(g, world.id)} ·{' '} + {world.native === me.id + ? 'native reserves' + : 'foreign garrison'} + +

+ Available: {pool.normal} normal + {pool.elite > 0 ? ` + ${pool.elite} ${special}` : ''}. +

+ {(['normal', 'elite'] as const) + .filter((kind) => kind === 'normal' || pool.elite > 0) + .map((kind) => ( + + ))} +
+ ); + })} + + {me.ally && ( + + )} +

+ Your available spice: {me.spice ?? 0}. Allied pledge:{' '} + {g.aid.available}. +

+ + {quote.amount} forces · {quote.cost} spice total + {quote.amount > 0 + ? ` · you pay ${quote.ownPayment}, ally pays ${quote.allyPayment}` + : ''} + .{quote.blocked && ` ${quote.blocked}`} + + +
+
+ ); +} diff --git a/components/homeworld-table.tsx b/components/homeworld-table.tsx new file mode 100644 index 00000000..963591d0 --- /dev/null +++ b/components/homeworld-table.tsx @@ -0,0 +1,246 @@ +'use client'; + +import { useState } from 'react'; +import type { Action, GameView } from '@/game/engine'; +import { faction } from '@/game/catalog'; +import { HOMEWORLD_CARDS } from '@/game/homeworld-cards'; +import type { NativeReserveSelections } from '@/game/homeworld-native-reserves'; +import { HomeworldFace } from './homeworld-cards'; +import { EcazPoisonIncome } from './ecaz-poison-income'; +import { Button } from './ui/button'; +import { Input } from './ui/input'; + +export function HomeworldTable({ game: g }: { game: GameView }) { + const worlds = g.homeworlds?.worlds; + if (!worlds) return null; + return ( +
+

Homeworlds

+

+ Native forces here are your reserves. Salusa Secundus counts Sardaukar + for its population. +

+ +
+ {worlds.map((world) => { + const card = HOMEWORLD_CARDS.find((c) => c.id === world.card)!; + const owner = g.players.find((p) => p.id === world.native)!; + return ( +
+
+ {card.name} + + {world.side} population · {world.population} + +
+

+ {faction(owner.faction).name} · {owner.name} +

+ {Object.entries(world.forces).map(([id, forces]) => ( +

+ {id === world.native + ? 'Native reserves' + : g.players.find((p) => p.id === id)?.name} + : {forces.normal} normal + {forces.elite > 0 + ? ` + ${forces.elite} ${owner.faction === 'emperor' && id === owner.id ? 'Sardaukar' : 'special'}` + : ''} +

+ ))} +
+ + {card.name} card + + +
+
+ ); + })} +
+
+ ); +} + +/** The complementary Kaitain allocation is explicit in the submitted order. */ +export function NativeShipmentChoice({ + game: g, + amount, + elite, + sources, + onChange, + busy, +}: { + game: GameView; + amount: number; + elite: number; + sources: NativeReserveSelections | null; + onChange: (sources: NativeReserveSelections) => void; + busy: boolean; +}) { + const homes = g.homeworlds?.worlds?.filter((w) => w.native === g.me); + if (!homes || homes.length !== 2 || !sources) return null; + const salusa = homes.find((w) => w.secondary)!; + const kaitain = homes.find((w) => !w.secondary)!; + const chosen = sources[salusa.id]; + const requested = { normal: amount - elite, elite }; + return ( +
+ Shipment sources +

+ Choose the counters leaving Salusa. The remainder leaves Kaitain in the + same shipment. +

+ {(['normal', 'elite'] as const).map((kind) => { + const minimum = Math.max( + 0, + requested[kind] - kaitain.forces[g.me][kind], + ); + const maximum = Math.min(requested[kind], salusa.forces[g.me][kind]); + return ( + + ); + })} +

+ Kaitain: {sources[kaitain.id].normal} normal +{' '} + {sources[kaitain.id].elite} Sardaukar. Salusa: {chosen.normal} normal +{' '} + {chosen.elite} Sardaukar. +

+
+ ); +} + +export function EmperorHomeworldMovement({ + game: g, + act, + busy, +}: { + game: GameView; + act: (action: Action) => void; + busy: boolean; +}) { + const [origin, setOrigin] = useState('homeworld:emperor'); + const [normal, setNormal] = useState(0); + const [elite, setElite] = useState(0); + const option = g.homeworldMove; + const homes = g.homeworlds?.worlds?.filter((w) => w.native === g.me); + if (!option || !homes || homes.length !== 2) return null; + const source = homes.find((w) => w.id === origin)!; + const destination = homes.find((w) => w.id !== origin)!; + const available = source.forces[g.me]; + const name = (id: string) => + id === 'homeworld:emperor' ? 'Kaitain' : 'Salusa Secundus'; + const invalid = + !Number.isSafeInteger(normal) || + !Number.isSafeInteger(elite) || + normal < 0 || + elite < 0 || + normal > available.normal || + elite > available.elite || + normal + elite === 0; + return ( +
+ + Move between Imperial Homeworlds + +

+ Move normal forces or Sardaukar between Kaitain and Salusa Secundus. + Costs one movement and no spice. Moving first passes your unused + shipment. +

+ {option.blocked && ( + {option.blocked} + )} +
+ + + +

+ {option.remaining} movement{option.remaining === 1 ? '' : 's'}{' '} + remaining. +

+ +
+
+ ); +} diff --git a/components/ix-substitution.tsx b/components/ix-substitution.tsx index 91c53454..bf28f78c 100644 --- a/components/ix-substitution.tsx +++ b/components/ix-substitution.tsx @@ -18,25 +18,42 @@ export function IxSubstitution({ if (g.decision?.kind !== 'ixSubstitution') return null; const d = g.decision; const me = g.players.find((p) => p.id === g.me)!; - const available = Object.entries(me.forces) - .filter(([key]) => splitLocation(key).territory === d.territory) - .map( - ([key, count]) => [key, count - (me.elites?.forces[key] ?? 0)] as const, - ) - .filter(([, count]) => count > 0); + const world = g.combatLocations?.find( + (place) => place.id === d.territory && place.kind === 'homeworld', + ); + const available = ( + world + ? [[world.id, world.forces?.[me.id]?.normal ?? 0] as const] + : Object.entries(me.forces) + .filter(([key]) => splitLocation(key).territory === d.territory) + .map( + ([key, count]) => + [key, count - (me.elites?.forces[key] ?? 0)] as const, + ) + ).filter(([, count]) => count > 0); const total = (value: Record) => Object.values(value).reduce((a, b) => a + b, 0); + const invalid = ( + selected: Record, + limits: Record, + ) => + Object.entries(selected).some( + ([key, count]) => + !Number.isSafeInteger(count) || count < 0 || count > (limits[key] ?? 0), + ); return ( <>

Exchange surviving suboids, one for one, to retain cyborgs lost in this - battle. Retained cyborgs return to their casualty sectors. Opponents may - cancel this substitution with Karama. + battle. Retained cyborgs return to{' '} + {world ? world.name : 'their casualty sectors'}. Opponents may cancel + this substitution with Karama.

Suboids to sacrifice

{available.map(([key, max]) => (