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
+47 -11
View File
@@ -1,16 +1,19 @@
'use client'
import { useCallback, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import type { ExploreResult, SystemState } from '@/lib/game/save'
import { TeamBuilder } from '@/components/team-builder'
import type { CrewState, ExploreResult, SystemState } from '@/lib/game/save'
import type { FoundItem } from '@/lib/game/world'
// LOCAL SYSTEM tab — the bodies of the current system (planets + staggered moons) with an
// EXPLORE button per unexplored row. The parent (MissionControl) owns the /api/system state so
// the right column's CARGO HOLD mirrors scans immediately; this panel reports each scan's
// response up via onChanged and keeps only its own UI bits locally (no polling). Turn-based:
// one scan costs a turn, each body once.
// EXPLORE button per unexplored row. A button click ARMS the planet (#44): the crew team
// builder mounts at the top of the column and its DISPATCH POSTs /api/explore with the team.
// The parent (MissionControl) owns the /api/system state so the right column's CARGO HOLD
// mirrors scans immediately; this panel reports each scan's response up via onChanged and
// keeps only its own UI bits locally (no polling). Turn-based: one scan costs a turn, each
// body once.
interface LocalSystemPanelProps {
system: SystemState | null
@@ -21,27 +24,50 @@ export function LocalSystemPanel({ system, onChanged }: LocalSystemPanelProps) {
const [lastOutcome, setLastOutcome] = useState<FoundItem[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [exploringId, setExploringId] = useState<number | null>(null)
// #44 — the armed planet (the last-clicked unexplored row); the builder above the list picks
// the team and its DISPATCH performs the scan.
const [armedPlanetId, setArmedPlanetId] = useState<number | null>(null)
const [crew, setCrew] = useState<CrewState | null>(null)
// JC-14 — the roster + queue the builder mirrors: fetched on mount and after every
// successful dispatch (the scan response updates the system 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])
const explore = useCallback(
async (planetId: number) => {
async (planetId: number, leadCrewId: number, helperCrewIds: number[]) => {
setExploringId(planetId)
setArmedPlanetId(null)
try {
const res = await fetch('/api/explore', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ planetId }),
body: JSON.stringify({ planetId, leadCrewId, helperCrewIds }),
})
const body = (await res.json()) as ExploreResult & { error?: string }
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`)
onChanged(body) // fresh state straight from the response — parent mirrors it to CARGO HOLD
setLastOutcome(body.outcome)
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 {
setExploringId(null)
}
},
[onChanged]
[onChanged, fetchCrew]
)
if (!system) {
@@ -57,6 +83,16 @@ export function LocalSystemPanel({ system, onChanged }: LocalSystemPanelProps) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{/* #44 — the armed planet's crew picker (JC-13: mounted only while a planet is armed);
its DISPATCH performs the scan with the picked team. */}
{armedPlanetId !== null && crew !== null && (
<TeamBuilder
type="EXPLORE"
roster={crew.crew}
tasks={crew.tasks}
onQueue={(leadCrewId, helperCrewIds) => void explore(armedPlanetId, leadCrewId, helperCrewIds)}
/>
)}
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', overflow: 'hidden' }}>
<div
style={{
@@ -108,9 +144,9 @@ export function LocalSystemPanel({ system, onChanged }: LocalSystemPanelProps) {
variant="OUTLINE"
size="SM"
disabled={exploringId !== null}
onClick={() => void explore(row.id)}
onClick={() => setArmedPlanetId(armedPlanetId === row.id ? null : row.id)} // #44 — arms/disarms; the builder dispatches
>
{exploringId === row.id ? 'SCANNING…' : `${row.name.toUpperCase()} // EXPLORE`}
{exploringId === row.id ? 'SCANNING…' : `${row.name.toUpperCase()} // ${armedPlanetId === row.id ? 'PICK TEAM' : 'EXPLORE'}`}
</Button>
)}
</div>
+80 -11
View File
@@ -1,16 +1,21 @@
'use client'
import { useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { TeamBuilder } from '@/components/team-builder'
import { BUILD_PART_COST, expandCost, totalQty, upgradeCost } from '@/lib/game/ship-costs'
import type { ShipState, ShipSystemDto } from '@/lib/game/ship'
import type { CrewState } from '@/lib/game/save'
import { buildGate, effectiveCapacityCap, effectiveLevelCap } from '@/lib/game/tech'
import type { TaskType } from '@/lib/game/tasks'
// SHIP SYSTEMS tab — live data. The parent (MissionControl) owns the ship state so the left
// column mirrors actions immediately; this panel applies each action's response directly via
// onChanged (no polling). Buttons are disabled when unaffordable, capped or gated by an
// onChanged (no polling). A card's UPGRADE / EXPAND / BUILD button ARMS the action (#44): the
// crew team builder mounts at the top of the column and its DISPATCH POSTs the ship route with
// the team. Buttons are disabled when unaffordable, capped or gated by an
// unresearched tech, using the same pure helpers the API enforces (lib/game/ship-costs.ts +
// lib/game/tech.ts).
@@ -24,6 +29,32 @@ interface ShipSystemsPanelProps {
export function ShipSystemsPanel({ ship, narrow, onChanged, researchedSlugs }: ShipSystemsPanelProps) {
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false) // one action at a time — turn-based game
// #44 — the armed ship action (which card + which of UPGRADE/EXPAND/BUILD); the builder above
// the grid picks the team and its DISPATCH performs it.
const [armed, setArmed] = useState<{ type: TaskType; url: string; body: Record<string, unknown>; systemId: number } | null>(null)
const [crew, setCrew] = useState<CrewState | null>(null)
// JC-14 — the roster + queue the builder mirrors: fetched on mount and after every
// successful dispatch (the action response updates the ship 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])
// #44 — arm an action (toggle: clicking the armed action's button again disarms it).
const arm = useCallback((type: TaskType, url: string, body: Record<string, unknown>, systemId: number) => {
setArmed((a) => (a && a.systemId === systemId && a.type === type ? null : { type, url, body, systemId }))
}, [])
if (!ship) {
return (
@@ -39,11 +70,13 @@ export function ShipSystemsPanel({ ship, narrow, onChanged, researchedSlugs }: S
async function act(url: string, body: unknown) {
setBusy(true)
setError(null)
setArmed(null)
try {
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
const data = (await res.json()) as ShipState & { error?: string }
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`)
onChanged(data) // response is the fresh ship state — apply directly
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 {
@@ -53,6 +86,16 @@ export function ShipSystemsPanel({ ship, narrow, onChanged, researchedSlugs }: S
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{/* #44 — the armed ship action's crew picker (JC-13: mounted only while an action is
armed); its DISPATCH performs it with the picked team. */}
{armed !== null && crew !== null && (
<TeamBuilder
type={armed.type}
roster={crew.crew}
tasks={crew.tasks}
onQueue={(leadCrewId, helperCrewIds) => void act(armed.url, { ...armed.body, leadCrewId, helperCrewIds })}
/>
)}
{error && (
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>{error.toUpperCase()}</span>
@@ -60,7 +103,16 @@ export function ShipSystemsPanel({ ship, narrow, onChanged, researchedSlugs }: S
)}
<div style={{ display: 'grid', gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)', gap: '0.75rem' }}>
{ship.systems.map((system) => (
<SystemCard key={system.id} system={system} resources={resources} parts={parts} busy={busy} researchedSlugs={researchedSlugs} onAct={act} />
<SystemCard
key={system.id}
system={system}
resources={resources}
parts={parts}
busy={busy}
researchedSlugs={researchedSlugs}
armedType={armed !== null && armed.systemId === system.id ? armed.type : null}
onArm={arm}
/>
))}
</div>
</div>
@@ -73,14 +125,16 @@ function SystemCard({
parts,
busy,
researchedSlugs,
onAct,
armedType,
onArm,
}: {
system: ShipSystemDto
resources: number
parts: number
busy: boolean
researchedSlugs: Set<string>
onAct: (url: string, body: unknown) => void
armedType: TaskType | null // #44 — which of this card's actions is the armed target
onArm: (type: TaskType, url: string, body: Record<string, unknown>, systemId: number) => void
}) {
const maxLevel = effectiveLevelCap(system.type, researchedSlugs)
const maxCapacity = effectiveCapacityCap(system.type, researchedSlugs)
@@ -116,16 +170,31 @@ function SystemCard({
<CardFooter className="gap-2">
{system.built ? (
<>
<Button variant="OUTLINE" size="SM" disabled={upgradeDisabled} onClick={() => onAct('/api/ship/upgrade', { systemId: system.id })}>
UPGRADE
<Button
variant="OUTLINE"
size="SM"
disabled={upgradeDisabled}
onClick={() => onArm('SHIP_UPGRADE', '/api/ship/upgrade', { systemId: system.id }, system.id)}
>
{armedType === 'SHIP_UPGRADE' ? 'PICK TEAM' : 'UPGRADE'}
</Button>
<Button variant="OUTLINE" size="SM" disabled={expandDisabled} onClick={() => onAct('/api/ship/expand', { systemId: system.id })}>
EXPAND
<Button
variant="OUTLINE"
size="SM"
disabled={expandDisabled}
onClick={() => onArm('SHIP_EXPAND', '/api/ship/expand', { systemId: system.id }, system.id)}
>
{armedType === 'SHIP_EXPAND' ? 'PICK TEAM' : 'EXPAND'}
</Button>
</>
) : (
<Button variant="OUTLINE" size="SM" disabled={buildDisabled} onClick={() => onAct('/api/ship/build', { type: system.type })}>
BUILD NEW
<Button
variant="OUTLINE"
size="SM"
disabled={buildDisabled}
onClick={() => onArm('SHIP_BUILD', '/api/ship/build', { type: system.type }, system.id)}
>
{armedType === 'SHIP_BUILD' ? 'PICK TEAM' : 'BUILD NEW'}
</Button>
)}
</CardFooter>
+52 -10
View File
@@ -1,16 +1,20 @@
'use client'
import { useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { TeamBuilder } from '@/components/team-builder'
import { totalQty } from '@/lib/game/ship-costs'
import type { ShipState } from '@/lib/game/ship'
import type { CrewState } from '@/lib/game/save'
import type { TechDto } from '@/lib/game/tech'
// SKILL TREE tab — live data. The parent (MissionControl) owns the tech state + ship state; this panel
// applies each research response directly via onResearched (no polling). RESEARCH is enabled only when
// the tech is AVAILABLE and its cost is payable from the cargo hold, mirroring what POST /api/research enforces.
// applies each research response directly via onResearched (no polling). A card's RESEARCH button ARMS
// the tech (#44): the crew team builder mounts at the top of the column and its DISPATCH POSTs
// /api/research with the team. The button is enabled only when the tech is AVAILABLE and its cost is
// payable from the cargo hold, mirroring what POST /api/research enforces.
interface SkillTreePanelProps {
techs: TechDto[] | null
@@ -22,6 +26,27 @@ interface SkillTreePanelProps {
export function SkillTreePanel({ techs, ship, narrow, onResearched }: SkillTreePanelProps) {
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false) // one action at a time — turn-based game
// #44 — the armed tech (last-clicked AVAILABLE card); the builder above the grid picks the
// team and its DISPATCH performs the research.
const [armedTechId, setArmedTechId] = useState<number | null>(null)
const [crew, setCrew] = useState<CrewState | null>(null)
// JC-14 — the roster + queue the builder mirrors: fetched on mount and after every
// successful dispatch (the research response updates the tech 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])
if (!techs || !ship) {
return (
@@ -34,14 +59,16 @@ export function SkillTreePanel({ techs, ship, narrow, onResearched }: SkillTreeP
const resources = totalQty(ship.inventory, 'resource')
const parts = totalQty(ship.inventory, 'part')
async function research(tech: TechDto) {
async function research(tech: TechDto, leadCrewId: number, helperCrewIds: number[]) {
setBusy(true)
setError(null)
setArmedTechId(null)
try {
const res = await fetch('/api/research', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ techId: tech.id }) })
const res = await fetch('/api/research', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ techId: tech.id, leadCrewId, helperCrewIds }) })
const data = (await res.json()) as TechDto[] & { error?: string }
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`)
onResearched(data) // response is the fresh tech tree state — apply directly
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 {
@@ -51,6 +78,19 @@ export function SkillTreePanel({ techs, ship, narrow, onResearched }: SkillTreeP
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{/* #44 — the armed tech's crew picker (JC-13: mounted only while a tech is armed);
its DISPATCH performs the research with the picked team. */}
{armedTechId !== null && crew !== null && (
<TeamBuilder
type="RESEARCH"
roster={crew.crew}
tasks={crew.tasks}
onQueue={(leadCrewId, helperCrewIds) => {
const tech = techs.find((t) => t.id === armedTechId)
if (tech) void research(tech, leadCrewId, helperCrewIds)
}}
/>
)}
{error && (
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>{error.toUpperCase()}</span>
@@ -58,7 +98,7 @@ export function SkillTreePanel({ techs, ship, narrow, onResearched }: SkillTreeP
)}
<div style={{ display: 'grid', gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)', gap: '0.75rem' }}>
{techs.map((tech) => (
<TechCard key={tech.slug} tech={tech} resources={resources} parts={parts} busy={busy} onResearch={research} />
<TechCard key={tech.slug} tech={tech} resources={resources} parts={parts} busy={busy} armed={armedTechId === tech.id} onArm={(t) => setArmedTechId(armedTechId === t.id ? null : t.id)} />
))}
</div>
</div>
@@ -70,13 +110,15 @@ function TechCard({
resources,
parts,
busy,
onResearch,
armed,
onArm,
}: {
tech: TechDto
resources: number
parts: number
busy: boolean
onResearch: (tech: TechDto) => void
armed: boolean // #44 — this card's tech is the armed target (the builder is open for it)
onArm: (tech: TechDto) => void
}) {
const affordable = tech.costKind === 'resource' ? resources >= tech.costQty : parts >= tech.costQty
const researchDisabled = busy || tech.state !== 'AVAILABLE' || !affordable
@@ -101,8 +143,8 @@ function TechCard({
)}
</CardContent>
<CardFooter>
<Button variant="OUTLINE" size="SM" disabled={researchDisabled} onClick={() => onResearch(tech)}>
RESEARCH
<Button variant="OUTLINE" size="SM" disabled={researchDisabled} onClick={() => onArm(tech)}>
{armed ? 'PICK TEAM' : 'RESEARCH'}
</Button>
</CardFooter>
</Card>
+81
View File
@@ -0,0 +1,81 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import type { CrewDto, QueueDto } from '@/lib/game/save'
import { computeDuration, type TaskType } from '@/lib/game/tasks'
// Shared crew picker for the four action panels — PR A: an action is queued as a lead +
// helpers task, not a one-click. Self-contained: the panel passes the /api/crew CrewState
// it fetched (roster + queue, Task 6) and onQueue hands back the picked team; the panel
// performs its own fetch with the team fields in the body (Task 5a). The live preview is
// Task 1's computeDuration (pure, no DB import) with the default powerOf — the same call
// 5a's route makes, so it equals the stored duration. The rows mirror Task 3's queueTask
// so the UI never offers a 409 (JC-11's fields make the member↔row check exact).
export function TeamBuilder({ type, hopCount, roster, tasks, onQueue }: {
type: TaskType
hopCount?: number
roster: CrewDto[]
tasks: QueueDto[]
onQueue: (leadCrewId: number, helperCrewIds: number[]) => void
}) {
const [leadId, setLeadId] = useState<number | null>(null)
const [helperIds, setHelperIds] = useState<number[]>([])
// Whole-builder blocks: an ACTIVE jump is in transit (all types); in the jump panel any
// queued jump is — the second-jump 409 (JC-12).
const inTransit = tasks.find((t) => t.type === 'JUMP' && t.status === 'ACTIVE')
const blocked = inTransit ?? (type === 'JUMP' ? tasks.find((t) => t.type === 'JUMP') : null)
const lead = roster.find((c) => c.id === leadId) ?? null
const helpers = roster.filter((c) => helperIds.includes(c.id))
const duration = lead ? computeDuration(type, hopCount ?? 0, lead, helpers) : null // CrewDto ⊇ CrewMember
const leadReason = (c: CrewDto) => {
const t = tasks.find((x) => x.leadCrewId === c.id && x.status === 'PENDING')
return t ? `${t.type} ALREADY QUEUED` : null
}
const helperReason = (c: CrewDto) => {
const t = tasks.find((x) => (x.leadCrewId === c.id || x.helperCrewIds.includes(c.id)) && (x.status === 'ACTIVE' || x.status === 'PENDING'))
return t ? `BUSY: ${t.type}` : null
}
return (
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem', display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<span style={{ fontSize: '0.6rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>PICK TEAM // {type}</span>
{blocked ? (
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>
{inTransit ? `IN TRANSIT // ${inTransit.targetLabel}` : 'A JUMP IS ALREADY QUEUED'}
</span>
) : (
roster.map((c) => (
<label key={c.id} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<input
type="radio"
name="lead"
checked={leadId === c.id}
disabled={leadReason(c) !== null}
onChange={() => {
setLeadId(c.id)
setHelperIds((h) => h.filter((x) => x !== c.id)) // a lead is never also a helper of the same task
}}
/>
<input type="checkbox" checked={helperIds.includes(c.id)} disabled={leadId === c.id || helperReason(c) !== null} onChange={() => setHelperIds((h) => (h.includes(c.id) ? h.filter((x) => x !== c.id) : [...h, c.id]))} />
<span style={{ fontSize: '0.65rem', color: leadId === c.id ? 'var(--text-secondary)' : 'var(--text-muted)', letterSpacing: '0.08em' }}>
{c.name}
{leadId === c.id ? ' // LEAD' : ''} {leadReason(c) ?? helperReason(c) ?? ''}
</span>
</label>
))
)}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
{duration !== null ? `WILL TAKE ${duration} TURN(S)` : 'PICK A LEAD'}
</span>
<Button variant="EXEC" size="SM" className="ml-auto" disabled={!lead || blocked !== null || leadReason(lead) !== null} onClick={() => lead && onQueue(lead.id, helperIds)}>
DISPATCH
</Button>
</div>
</div>
)
}
+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 && (
+6 -6
View File
@@ -581,8 +581,8 @@ describe('snapshot task queue (world / system / crew)', () => {
const jumpId = await insertTaskRow(db, { type: 'JUMP', payload: { systemId: f.sys1, hops: 1 }, leadCrewId: c1, helpers: [], duration: 2, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 })
const exploreId = await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p1 }, leadCrewId: c2, helpers: [c3], duration: 4, remaining: 4, status: 'PENDING', queuedAtTurn: 3 })
const expected = [
{ id: jumpId, type: 'JUMP', targetLabel: 'Outpost', leadName: 'CREW 1', helperNames: [], remaining: 1 },
{ id: exploreId, type: 'EXPLORE', targetLabel: 'Basalt', leadName: 'CREW 2', helperNames: ['CREW 3'], remaining: 4 },
{ id: jumpId, type: 'JUMP', targetLabel: 'Outpost', leadName: 'CREW 1', helperNames: [], remaining: 1, status: 'ACTIVE', leadCrewId: c1, helperCrewIds: [] },
{ id: exploreId, type: 'EXPLORE', targetLabel: 'Basalt', leadName: 'CREW 2', helperNames: ['CREW 3'], remaining: 4, status: 'PENDING', leadCrewId: c2, helperCrewIds: [c3] },
]
expect((await getWorldState(db)).tasks).toEqual(expected)
expect((await getSystemState(db)).tasks).toEqual(expected)
@@ -608,8 +608,8 @@ describe('snapshot task queue (world / system / crew)', () => {
const exploreId = await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c2, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 })
const snap = await getWorldState(db) // must not throw out of the read
expect(snap.tasks).toEqual([
{ id: badId, type: 'EXPLORE', targetLabel: 'unknown', leadName: 'CREW 1', helperNames: [], remaining: 1 },
{ id: exploreId, type: 'EXPLORE', targetLabel: 'Alder', leadName: 'CREW 2', helperNames: [], remaining: 1 }, // the well-formed sibling still resolves
{ id: badId, type: 'EXPLORE', targetLabel: 'unknown', leadName: 'CREW 1', helperNames: [], remaining: 1, status: 'ACTIVE', leadCrewId: c1, helperCrewIds: [] },
{ id: exploreId, type: 'EXPLORE', targetLabel: 'Alder', leadName: 'CREW 2', helperNames: [], remaining: 1, status: 'ACTIVE', leadCrewId: c2, helperCrewIds: [] }, // the well-formed sibling still resolves
])
})
@@ -620,7 +620,7 @@ describe('snapshot task queue (world / system / crew)', () => {
const badId = await insertRawTaskRow(db, { type: 'JUMP', payload: JSON.stringify({ systemId: f.sys1, hops: 1 }), helpers: '[not-json', leadCrewId: c1, duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 })
const snap = await getWorldState(db)
expect(snap.tasks).toEqual([
{ id: badId, type: 'JUMP', targetLabel: 'Outpost', leadName: 'CREW 1', helperNames: [], remaining: 1 },
{ id: badId, type: 'JUMP', targetLabel: 'Outpost', leadName: 'CREW 1', helperNames: [], remaining: 1, status: 'ACTIVE', leadCrewId: c1, helperCrewIds: [] },
])
})
@@ -631,7 +631,7 @@ describe('snapshot task queue (world / system / crew)', () => {
const badId = await insertRawTaskRow(db, { type: 'TELEPORT', payload: JSON.stringify({ systemId: f.sys1, hops: 1 }), helpers: '[]', leadCrewId: c1, duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 })
const snap = await getWorldState(db)
expect(snap.tasks).toEqual([
{ id: badId, type: 'TELEPORT', targetLabel: 'unknown', leadName: 'CREW 1', helperNames: [], remaining: 1 },
{ id: badId, type: 'TELEPORT', targetLabel: 'unknown', leadName: 'CREW 1', helperNames: [], remaining: 1, status: 'ACTIVE', leadCrewId: c1, helperCrewIds: [] },
])
})
})
+11 -3
View File
@@ -26,7 +26,10 @@ export interface WorldSystemDto {
// One row of the snapshot task queue — what the GET builders expose of the tasks table
// (distinct from the tick's TickResult, which reports every row including DONE): the display
// fields the UI needs, names resolved, no raw payload.
// fields the UI needs, names resolved, no raw payload — plus the raw row state (JC-11):
// `status` (the array's ACTIVE-block-first order isn't readable back from it) and the crew
// ids, which the team builder needs for exact member↔row matching (name matching breaks on
// the `unknown` fallback).
export interface QueueDto {
id: number
type: TaskType
@@ -34,6 +37,9 @@ export interface QueueDto {
leadName: string
helperNames: string[]
remaining: number
status: TaskStatus // ACTIVE block first in the array, then PENDING
leadCrewId: number // crew.id of the team lead
helperCrewIds: number[] // crew.id of the supporting crew (degrades to [] on a corrupted blob, like helperNames)
}
export interface WorldState {
@@ -251,12 +257,14 @@ async function taskQueue(db: GameDb): Promise<QueueDto[]> {
helperNames: parseHelpers(t.helpers).map(crewName),
remaining: t.remaining,
status: t.status as TaskStatus,
leadCrewId: t.leadCrewId,
helperCrewIds: parseHelpers(t.helpers),
queuedAtTurn: t.queuedAtTurn,
}))
const byQueueOrder = (rows: typeof queue) => rows.sort((a, b) => a.queuedAtTurn - b.queuedAtTurn || a.id - b.id)
const ordered = [...byQueueOrder(queue.filter((t) => t.status === 'ACTIVE')), ...byQueueOrder(queue.filter((t) => t.status === 'PENDING'))]
// The sort keys are only for ordering — the exposed shape is exactly QueueDto.
return ordered.map(({ status, queuedAtTurn, ...dto }) => dto)
// queuedAtTurn is only for ordering — the exposed shape is exactly QueueDto (status stays: JC-11).
return ordered.map(({ queuedAtTurn, ...dto }) => dto)
}
export async function getWorldState(db: GameDb): Promise<WorldState> {
+4 -2
View File
@@ -131,9 +131,11 @@ describe('OpenAPI spec', () => {
expect((crewState.properties.tasks as { items: { $ref: string } }).items.$ref).toBe('#/components/schemas/TaskDto');
const taskDto = openApiSpec.components.schemas.TaskDto as { properties: Record<string, unknown>; required: string[] };
expect(Object.keys(taskDto.properties)).toEqual(['id', 'type', 'targetLabel', 'leadName', 'helperNames', 'remaining']);
expect(taskDto.required).toEqual(['id', 'type', 'targetLabel', 'leadName', 'helperNames', 'remaining']);
expect(Object.keys(taskDto.properties)).toEqual(['id', 'type', 'targetLabel', 'leadName', 'helperNames', 'remaining', 'status', 'leadCrewId', 'helperCrewIds']);
expect(taskDto.required).toEqual(['id', 'type', 'targetLabel', 'leadName', 'helperNames', 'remaining', 'status', 'leadCrewId', 'helperCrewIds']);
expect((taskDto.properties.type as { enum: string[] }).enum).toEqual(['JUMP', 'EXPLORE', 'SHIP_UPGRADE', 'SHIP_EXPAND', 'SHIP_BUILD', 'RESEARCH']);
expect((taskDto.properties.status as { enum: string[] }).enum).toEqual(['ACTIVE', 'PENDING']);
expect((taskDto.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer');
const member = openApiSpec.components.schemas.CrewMember.properties;
expect(Object.keys(member)).toEqual(['id', 'name', 'role', 'trait_good', 'trait_bad', 'origin']);
+12 -1
View File
@@ -913,8 +913,19 @@ export const openApiSpec = {
leadName: { type: 'string', description: 'The team lead by name; `unknown` when the crew row no longer exists' },
helperNames: { type: 'array', description: 'The supporting crew by name (empty when none)', items: { type: 'string' } },
remaining: { type: 'integer', description: 'Turns left — the stored row value (a pure read, not recomputed)' },
status: {
type: 'string',
enum: ['ACTIVE', 'PENDING'],
description: 'The row state — the ACTIVE block precedes the PENDING block in the array',
},
leadCrewId: { type: 'integer', description: 'crew.id of the team lead' },
helperCrewIds: {
type: 'array',
description: 'crew.id of the supporting crew (empty when none)',
items: { type: 'integer' },
},
},
required: ['id', 'type', 'targetLabel', 'leadName', 'helperNames', 'remaining'],
required: ['id', 'type', 'targetLabel', 'leadName', 'helperNames', 'remaining', 'status', 'leadCrewId', 'helperCrewIds'],
},
JumpRequest: {
type: 'object',