feat(ui): team builder — lead + helpers with live duration preview, wired into the action panels (#44)
This commit is contained in:
+80
-11
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user