import { useMemo, useState } from 'react'; import type { MissionInfo } from '@reforger-panel/shared'; import { useConfiguration, useMissions, useSetPerformanceSettings } from '../api/hooks.js'; import { Badge, Button, Card, EmptyState, Notice, SearchInput, Spinner, useToast } from './ui.js'; import { Icon } from './icons.js'; const SCENARIO_PATTERN = /^\{[0-9A-Fa-f]{16}\}[^\0\r\n]+\.conf$/; /** Display form of a scenario id: just the file name, e.g. "23_Campaign.conf". */ export function shortScenario(scenarioId: string): string { const slash = scenarioId.lastIndexOf('/'); return slash >= 0 ? scenarioId.slice(slash + 1) : scenarioId; } /** * Mission picker. * * Scenario discovery is now reliable: the vanilla list is bundled and merged * with whatever the server prints at boot, and modded scenarios come from the * Workshop v2 `scenarios[].gameId` field rather than being scraped out of prose. * The raw id input is kept, but demoted to a fallback. */ export function MissionCard({ slug, canEdit }: { slug: string; canEdit: boolean }) { const toast = useToast(); const { data: config, refetch: refetchConfig } = useConfiguration(slug); const { data: missions, isLoading: missionsLoading, refetch: refetchMissions, } = useMissions(slug); const save = useSetPerformanceSettings(slug); const [query, setQuery] = useState(''); const [manual, setManual] = useState(''); const [showManual, setShowManual] = useState(false); const current = config?.config.scenarioId ?? ''; const groups = useMemo(() => { if (!missions) return []; const needle = query.trim().toLowerCase(); if (!needle) return missions.groups; return missions.groups .map((group) => ({ ...group, missions: group.missions.filter( (mission) => mission.name.toLowerCase().includes(needle) || mission.scenarioId.toLowerCase().includes(needle) || (mission.gameMode ?? '').toLowerCase().includes(needle), ), })) .filter((group) => group.missions.length > 0); }, [missions, query]); const known = useMemo( () => new Set( (missions?.groups ?? []).flatMap((group) => group.missions.map((mission) => mission.scenarioId), ), ), [missions], ); const currentMission = useMemo(() => { for (const group of missions?.groups ?? []) { const match = group.missions.find((mission) => mission.scenarioId === current); if (match) return { mission: match, groupLabel: group.label }; } return null; }, [missions, current]); const apply = (scenarioId: string) => { if (!SCENARIO_PATTERN.test(scenarioId)) { toast('That does not look like a scenario id ({16 hex}Missions/….conf).', 'danger'); return; } save.mutate( { settings: { scenarioId }, expectedRevision: config?.revision, writeStartupVars: true, }, { onSuccess: () => { setManual(''); void refetchConfig(); toast('Mission saved to config.json. Restart the server to switch.', 'ok'); }, onError: (error) => toast(error.message, 'danger'), }, ); }; if (!config) { return ( ); } return ( )} } >

Currently configured

{currentMission?.mission.name ?? shortScenario(current)} {currentMission && {currentMission.groupLabel}} {currentMission?.mission.gameMode && {currentMission.mission.gameMode}}

{current || '(none set)'}

{!missionsLoading && current && !known.has(current) && ( The server is configured for a scenario the base game does not ship and no installed mod offers. It will fail to load it on the next restart — pick one below, or re-add the mod that provided it. )} {(missions?.incompleteModIds.length ?? 0) > 0 && ( {missions!.incompleteModIds.length} installed mod {missions!.incompleteModIds.length === 1 ? "'s" : "s'"} scenarios could not be read from the Workshop, so this list may be incomplete. )} {canEdit && showManual && (
setManual(event.target.value)} className="input font-mono text-xs" />
)} {missionsLoading ? ( ) : groups.length === 0 ? ( ) : (
{groups.map((group) => (

{group.label} {group.missions.length}

    {group.missions.map((mission) => ( apply(mission.scenarioId)} /> ))}
))}
)}
); } function MissionRow({ mission, active, canEdit, saving, onSelect, }: { mission: MissionInfo; active: boolean; canEdit: boolean; saving: boolean; onSelect: () => void; }) { return (
  • {mission.name} {active && running}

    {mission.scenarioId}

    {mission.gameMode && {mission.gameMode}} {mission.playerCount ? ( {mission.playerCount}p ) : null} {canEdit && ( )}
  • ); }