diff --git a/components/local-system.tsx b/components/local-system.tsx index e8745cb..a006c5a 100644 --- a/components/local-system.tsx +++ b/components/local-system.tsx @@ -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(null) const [error, setError] = useState(null) const [exploringId, setExploringId] = useState(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(null) + const [crew, setCrew] = useState(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 (
+ {/* #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 && ( + void explore(armedPlanetId, leadCrewId, helperCrewIds)} + /> + )}
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'}`} )}
diff --git a/components/ship-systems.tsx b/components/ship-systems.tsx index 8588381..9a35d6f 100644 --- a/components/ship-systems.tsx +++ b/components/ship-systems.tsx @@ -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(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; systemId: number } | null>(null) + const [crew, setCrew] = useState(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, 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 (
+ {/* #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 && ( + void act(armed.url, { ...armed.body, leadCrewId, helperCrewIds })} + /> + )} {error && (
{error.toUpperCase()} @@ -60,7 +103,16 @@ export function ShipSystemsPanel({ ship, narrow, onChanged, researchedSlugs }: S )}
{ship.systems.map((system) => ( - + ))}
@@ -73,14 +125,16 @@ function SystemCard({ parts, busy, researchedSlugs, - onAct, + armedType, + onArm, }: { system: ShipSystemDto resources: number parts: number busy: boolean researchedSlugs: Set - 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, systemId: number) => void }) { const maxLevel = effectiveLevelCap(system.type, researchedSlugs) const maxCapacity = effectiveCapacityCap(system.type, researchedSlugs) @@ -116,16 +170,31 @@ function SystemCard({ {system.built ? ( <> - - ) : ( - )} diff --git a/components/skill-tree.tsx b/components/skill-tree.tsx index 8a3827a..f70eed9 100644 --- a/components/skill-tree.tsx +++ b/components/skill-tree.tsx @@ -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(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(null) + const [crew, setCrew] = useState(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 (
+ {/* #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 && ( + { + const tech = techs.find((t) => t.id === armedTechId) + if (tech) void research(tech, leadCrewId, helperCrewIds) + }} + /> + )} {error && (
{error.toUpperCase()} @@ -58,7 +98,7 @@ export function SkillTreePanel({ techs, ship, narrow, onResearched }: SkillTreeP )}
{techs.map((tech) => ( - + setArmedTechId(armedTechId === t.id ? null : t.id)} /> ))}
@@ -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({ )} - diff --git a/components/team-builder.tsx b/components/team-builder.tsx new file mode 100644 index 0000000..9746fac --- /dev/null +++ b/components/team-builder.tsx @@ -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(null) + const [helperIds, setHelperIds] = useState([]) + + // 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 ( +
+ PICK TEAM // {type} + {blocked ? ( + + {inTransit ? `IN TRANSIT // ${inTransit.targetLabel}` : 'A JUMP IS ALREADY QUEUED'} + + ) : ( + roster.map((c) => ( + + )) + )} +
+ + {duration !== null ? `WILL TAKE ${duration} TURN(S)` : 'PICK A LEAD'} + + +
+
+ ) +} diff --git a/components/world-map.tsx b/components/world-map.tsx index 1b772fd..217f1cc 100644 --- a/components/world-map.tsx +++ b/components/world-map.tsx @@ -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(null) const [error, setError] = useState(null) const [jumpingId, setJumpingId] = useState(null) + // #44 — the team builder: the clicked node is the armed target; its DISPATCH performs the jump. + const [crew, setCrew] = useState(null) + const [armedId, setArmedId] = useState(null) const containerRef = useRef(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 (
- {/* 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). */}
@@ -293,6 +333,29 @@ export function WorldMapPanel({ height = 380, narrow = false, navAi = false, onJ GALACTIC CHART // JUMP ROUTES — TURN {world.turn}
+ + {/* #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 && ( +
+ void jump(armedId, leadCrewId, helperCrewIds)} + /> +
+ )}
{error && ( diff --git a/lib/game/save.test.ts b/lib/game/save.test.ts index 1f16627..56fad68 100644 --- a/lib/game/save.test.ts +++ b/lib/game/save.test.ts @@ -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: [] }, ]) }) }) diff --git a/lib/game/save.ts b/lib/game/save.ts index 9d645a4..0d058f6 100644 --- a/lib/game/save.ts +++ b/lib/game/save.ts @@ -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 { 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 { diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts index 6dc1616..175bc38 100644 --- a/lib/openapi.test.ts +++ b/lib/openapi.test.ts @@ -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; 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']); diff --git a/lib/openapi.ts b/lib/openapi.ts index 38c58d5..9619ade 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -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',