feat(ui): skill levels + titles on CREW tab and team-builder (#45)
CI / build + unit + e2e (pull_request) Successful in 3m7s
CI / build + unit + e2e (pull_request) Successful in 3m7s
This commit is contained in:
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user