feat(ui): team builder — lead + helpers with live duration preview, wired into the action panels (#44)

This commit is contained in:
2026-10-02 20:34:14 +00:00
parent 07c1508999
commit a199b64348
9 changed files with 374 additions and 62 deletions
+81 -18
View File
@@ -1,16 +1,18 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { forceCenter, forceCollide, forceLink, forceManyBody, forceSimulation } from 'd3-force'
import type { SimulationNodeDatum } from 'd3-force'
import { select } from 'd3-selection'
import { zoom, zoomIdentity } from 'd3-zoom'
import type { WorldState } from '@/lib/game/save'
import { TeamBuilder } from '@/components/team-builder'
import type { CrewState, WorldState } from '@/lib/game/save'
import { reachableWithin } from '@/lib/game/world'
// WORLD MAP tab — real world state from /api/world on a force-directed chart (d3-force) with
// pan + zoom (d3-zoom), per #28. Clicking a system within jump range of the current one jumps
// there (same POST /api/jump flow as before; two hops away once NAVIGATION AI is researched,
// pan + zoom (d3-zoom), per #28. Clicking a system within jump range of the current one ARMS
// it (#44): the crew team builder mounts as a floating card over the chart, and its DISPATCH
// POSTs /api/jump with the team in the body (two hops away once NAVIGATION AI is researched,
// #35); the current and unreachable systems are dimmed and not
// clickable, and every node is locked while a jump is in flight. Turn-based: fetch on mount,
// refetch via the jump response — no polling. In wide mode (#36) the chart fills the whole tab
@@ -55,6 +57,9 @@ export function WorldMapPanel({ height = 380, narrow = false, navAi = false, onJ
const [world, setWorld] = useState<WorldState | null>(null)
const [error, setError] = useState<string | null>(null)
const [jumpingId, setJumpingId] = useState<number | null>(null)
// #44 — the team builder: the clicked node is the armed target; its DISPATCH performs the jump.
const [crew, setCrew] = useState<CrewState | null>(null)
const [armedId, setArmedId] = useState<number | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
@@ -75,53 +80,85 @@ export function WorldMapPanel({ height = 380, narrow = false, navAi = false, onJ
}
}, [])
// JC-14 — the roster + queue the builder mirrors: fetched on mount and after every
// successful dispatch (the jump response updates the world state, not the roster).
// Keep-old-on-failure, the house .catch(() => {}) pattern.
const fetchCrew = useCallback(() => {
fetch('/api/crew')
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return (await res.json()) as CrewState
})
.then((c) => setCrew(c))
.catch(() => {})
}, [])
useEffect(() => {
fetchCrew()
}, [fetchCrew])
// #35 — the jump-reach map: one hop away, or two once NAVIGATION AI is researched. The chart
// marks its fields clickable from it, and the team builder previews the armed target's
// duration from its hop count (the same logic /api/jump recomputes).
const hopMap = useMemo(() => {
if (!world) return null
const current = world.systems.find((s) => s.current)
return current ? reachableWithin(world.edges, current.id, navAi ? 2 : 1) : null
}, [world, navAi])
// Ref guard so a double-click can't start two jumps before the re-render locks the nodes.
const jumpingRef = useRef(false)
const jump = useCallback(async (systemId: number) => {
// #44 — a node click ARMS the target instead of jumping: the builder over the chart picks the
// team, and its DISPATCH performs the fetch (below). Clicking another jumpable node retargets.
const arm = useCallback((systemId: number) => {
if (jumpingRef.current) return
setArmedId(systemId)
}, [])
const jump = useCallback(async (systemId: number, leadCrewId: number, helperCrewIds: number[]) => {
if (jumpingRef.current) return
jumpingRef.current = true
setJumpingId(systemId)
setArmedId(null)
try {
const res = await fetch('/api/jump', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ systemId }),
body: JSON.stringify({ systemId, leadCrewId, helperCrewIds }),
})
const body = (await res.json()) as WorldState & { error?: string }
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`)
setWorld(body) // fresh state straight from the response — no refetch needed
onJumped?.() // #30 — parent re-fetches /api/system (LOCAL SYSTEM + CARGO HOLD mirror it)
fetchCrew() // JC-14 — the dispatch committed a team; refresh the roster + queue for the builder
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err))
} finally {
jumpingRef.current = false
setJumpingId(null)
}
}, [onJumped])
}, [onJumped, fetchCrew])
// Rebuild the chart on every world/lock change — the force layout re-settles from the API's
// coordinates, so nodes visibly rearrange after each jump (#28).
useEffect(() => {
const container = containerRef.current
if (!container || !world) return
if (!container || !world || !hopMap) return
// Measured pane size (#36): the flex chain gives a definite box in wide mode; `height` is the
// fallback when the container has no resolved size yet (narrow flow, or tab hidden on load).
const W = container.clientWidth || 800
const H = container.clientHeight || height
const current = world.systems.find((s) => s.current)!
// Clickable chart fields: one hop away, or two hops once NAVIGATION AI is researched (#35).
const hops = reachableWithin(world.edges, current.id, navAi ? 2 : 1)
const nodes: ChartNode[] = world.systems.map((s) => ({
id: s.id,
name: s.name.toUpperCase(),
status: s.current ? 'current' : hops.has(s.id) ? 'jumpable' : 'offline',
status: s.current ? 'current' : hopMap.has(s.id) ? 'jumpable' : 'offline',
sublabel: s.current
? 'CURRENT'
: hops.has(s.id)
? `${s.discovered ? 'DISCOVERED' : 'UNKNOWN'} // JUMP${(hops.get(s.id) ?? 1) > 1 ? ' ×2' : ''}`
: hopMap.has(s.id)
? `${s.discovered ? 'DISCOVERED' : 'UNKNOWN'} // JUMP${(hopMap.get(s.id) ?? 1) > 1 ? ' ×2' : ''}`
: s.discovered ? 'DISCOVERED' : 'UNKNOWN',
x: (s.x / 100) * W, // seed from the API coordinates so the layout settles near the old chart
y: (s.y / 100) * H,
@@ -187,18 +224,19 @@ export function WorldMapPanel({ height = 380, narrow = false, navAi = false, onJ
// Same accessible contract as the old chart: only jumpable nodes are role=button with their
// name as the accessible label (e2e clicks them by role/name); all locked while a jump is in flight.
// A click ARMS the node (#44) — the team builder over the chart performs the jump.
nodeSel.filter((d) => d.status === 'jumpable' && jumpingId === null)
.attr('role', 'button')
.attr('tabindex', 0)
.attr('aria-label', (d) => d.name)
.style('cursor', 'pointer')
.on('click', (event: MouseEvent, d) => {
if (!panned) void jump(d.id) // a real pan ends with a click too — don't jump on that one
if (!panned) arm(d.id) // a real pan ends with a click too — don't arm on that one
})
.on('keydown', (event: KeyboardEvent, d) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
void jump(d.id)
arm(d.id)
}
})
@@ -246,7 +284,7 @@ export function WorldMapPanel({ height = 380, narrow = false, navAi = false, onJ
simulation.stop()
svg.remove()
}
}, [world, jumpingId, jump, height, navAi])
}, [world, jumpingId, arm, height, navAi, hopMap])
if (error && !world) {
return (
@@ -269,13 +307,15 @@ export function WorldMapPanel({ height = 380, narrow = false, navAi = false, onJ
// flex (not height:'100%'): the pane chain is sized by min-height, where percentages don't resolve
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', ...(filling ? { flex: 1, minHeight: 0 } : {}) }}>
{/* Wrapper + title bar; in wide mode the box flexes to fill the whole pane (#36). d3 owns everything inside it. */}
{/* Wrapper + title bar; in wide mode the box flexes to fill the whole pane (#36). d3 owns the
chart container inside it; the armed target's team builder floats over the chart (#44). */}
<div
style={{
border: '1px solid var(--border)',
background: 'var(--surface)',
fontFamily: 'var(--font-mono)',
overflow: 'hidden',
position: 'relative',
...(filling ? { flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' } : {}),
}}
>
@@ -293,6 +333,29 @@ export function WorldMapPanel({ height = 380, narrow = false, navAi = false, onJ
GALACTIC CHART // JUMP ROUTES — TURN {world.turn}
</div>
<div ref={containerRef} style={{ width: '100%', ...(filling ? { flex: 1, minHeight: 0 } : { height: `${height}px` }) }} />
{/* #44 — the armed target's crew picker, floating over the chart (JC-13: mounted only
while a target is armed); its DISPATCH performs the jump with the picked team. */}
{armedId !== null && crew !== null && (
<div
style={{
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
width: 'min(24rem, calc(100% - 1.5rem))',
zIndex: 10,
}}
>
<TeamBuilder
type="JUMP"
hopCount={hopMap?.get(armedId) ?? 0}
roster={crew.crew}
tasks={crew.tasks}
onQueue={(leadCrewId, helperCrewIds) => void jump(armedId, leadCrewId, helperCrewIds)}
/>
</div>
)}
</div>
{error && (