feat(ui): skill levels + titles on CREW tab and team-builder (#45)
CI / build + unit + e2e (pull_request) Successful in 3m7s

This commit is contained in:
2026-10-04 03:22:51 +00:00
parent 9d1a8e9d89
commit 1a36160392
8 changed files with 146 additions and 25 deletions
+18 -2
View File
@@ -9,6 +9,12 @@ import type { CrewDto, CrewState } from '@/lib/game/save'
// /api/crew on mount; Radix unmounts inactive tabs, so opening this tab always refetches
// (no polling needed — turn-based game). Display-only for now: no dismiss/assign actions yet.
// Spec §8: the three combat traits that carry no duration effect yet — their effect lands
// with the encounters iteration, so the trait line labels them encounter-ready (STANDBY).
// The label is additive — the original name always appears in the text unchanged.
const STANDBY_TRAITS = new Set(['SHARP SHOOTER', 'SHORT FUSE', 'JINXED IN A DOGFIGHT'])
const standbyLabel = (trait: string) => (STANDBY_TRAITS.has(trait) ? `${trait} (STANDBY — encounter-ready)` : trait)
interface CrewPanelProps {
narrow?: boolean
}
@@ -80,15 +86,25 @@ export function CrewPanel({ narrow }: CrewPanelProps) {
>
{member.role}
</p>
{/* The member's four skill levels, fixed order (JC-27) — one `·`-separated line. */}
<p style={{ fontSize: '0.6rem', color: 'var(--text-secondary)', letterSpacing: '0.08em' }}>
{member.skills.map((s) => `${s.skill} L${s.level}`).join(' · ')}
</p>
{/* The title only exists at L3+ (SENIOR) / L5 (MASTER) — titleFor returns null below. */}
{member.title !== null && (
<p style={{ fontSize: '0.62rem', color: 'var(--color-green)', letterSpacing: '0.1em' }}>
{member.title}
</p>
)}
</CardHeader>
<CardContent style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
<div style={{ fontSize: '0.7rem', letterSpacing: '0.05em' }}>
<span style={{ color: 'var(--color-green)' }}>+ </span>
<span style={{ color: 'var(--text-secondary)' }}>{member.traitGood}</span>
<span style={{ color: 'var(--text-secondary)' }}>{standbyLabel(member.traitGood)}</span>
</div>
<div style={{ fontSize: '0.7rem', letterSpacing: '0.05em' }}>
<span style={{ color: 'var(--color-red)' }}>- </span>
<span style={{ color: 'var(--text-secondary)' }}>{member.traitBad}</span>
<span style={{ color: 'var(--text-secondary)' }}>{standbyLabel(member.traitBad)}</span>
</div>
<p style={{ fontSize: '0.6rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
ORIGIN // {member.origin.toUpperCase()}
+14 -5
View File
@@ -4,14 +4,17 @@ 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'
import { makePowerOf, type SkillRow } from '@/lib/game/skills'
// 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).
// Task 1's computeDuration (pure, no DB import) with the skill-band powerOf — Task 14's
// makePowerOf over the roster's { skill, level } rows (R-P3: the same computation the
// queueing routes make, so the preview equals the stored duration); each member row shows
// that member's power contribution for the armed action's skill. 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
@@ -31,9 +34,15 @@ export function TeamBuilder({ type, hopCount, roster, tasks, onQueue }: {
const queuedJump = type === 'JUMP' ? (tasks.find((t) => t.type === 'JUMP') ?? null) : null
const blocked = inTransit ?? queuedJump
// R-P3: the preview's powerOf is the same makePowerOf the queueing routes call (Task 14) —
// built from the roster's { skill, level } rows (JC-26), so the estimate equals the stored
// duration instead of assuming every member is power 1.
const skillRows: SkillRow[] = roster.flatMap((c) => c.skills.map((s) => ({ saveId: '', crewId: String(c.id), skill: s.skill, level: s.level, xp: 0 })))
const powerOf = makePowerOf(roster, skillRows, type)
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 duration = lead ? computeDuration(type, hopCount ?? 0, lead, helpers, powerOf) : null // CrewDto ⊇ CrewMember
const leadReason = (c: CrewDto) => {
const leads = tasks.find((x) => x.leadCrewId === c.id && x.status === 'PENDING')
@@ -68,7 +77,7 @@ export function TeamBuilder({ type, hopCount, roster, tasks, onQueue }: {
/>
<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}
{c.name} (power {powerOf(c)})
{leadId === c.id ? ' // LEAD' : ''} {leadReason(c) ?? helperReason(c) ?? ''}
</span>
</label>