Files
Flight-Ops/components/RightPane.tsx
T
kamilkrzywda a801311fc5
CI / build + unit + e2e (pull_request) Failing after 5m28s
feat: v0.16.0 — ISS-005 weight-proportional fuel burn + refuel just enough for the loaded package
Part A: r(F) = k(We + rho*F) L/km, k calibrated so a full tank burns exactly
the old constant; closed forms in lib/sim/movement.ts (fuelAtWeighted,
requiredFuelFor, burnRateConstant, FUEL_DENSITY_KG_PER_L). Catalog gains
emptyWeightKg (ATR72 OEW 9300 kg), exposed in the snapshot. All three burn
call sites (engine landing, state live fuel, fly validation) moved to the
weighted model; old constant-rate fuelAt removed.

Part B: POST /api/planes/{planeId}/refuel takes an optional body
{mode:'full'|'forFlight', packageId} — no body keeps today's fill-to-full
behavior exactly (fuel lands bit-exact on maxFuel); forFlight adds just
enough for the loaded package's destination leg (409 not-loaded/expired/
doesn't-fit, 400 bad body). UI: per-cargo-row 'Refuel' button
(refuel-for-{pkg.id}), market button renamed 'Refuel to full'.

OpenAPI: RefuelRequest schema + optional requestBody + 400 on refuel, fly's
409 description updated, CatalogEntry.emptyWeightKg. Tests: new weighted-burn
suite, forFlight action/route cases (incl. ~7670 km UNNT<->LPPD doesn't-fit),
openapi guards; landing-fuel assertions in engine.test.ts + e2e/actions.spec.ts
use the closed form; the e2e 'return leg unfuelable' scenario reworked — a full
tank now covers the EDDF<->LEZL round trip, so the 409 moves to the second
outbound. New e2e: row Refuel deducts exactly L*price / raises fuel by exactly
L via the endpoint's own response snapshot.
2026-09-19 12:24:34 +00:00

426 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useCallback, useEffect, useState } from 'react';
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
import { PLANE_TYPES } from '@/lib/game/catalog';
import { abandonPenalty } from '@/lib/game/penalty';
import { formatCash, formatDeadlineRelative } from '@/lib/format';
import type { HoverTarget } from './hoverTypes';
interface RightPaneProps {
state: StateSnapshot | null;
loading: boolean;
// Push the full snapshot returned by an action endpoint into the shared shell state (v0.10.0),
// so the pane updates on click without waiting for the next poll tick.
applySnapshot: (snapshot: StateSnapshot) => void;
// ISS-002 (v0.15.0): report row hovers to the shell so the map can highlight the plane or
// the package's endpoint airports.
onHover: (target: HoverTarget | null) => void;
}
const planeName = (p: PlaneState): string => PLANE_TYPES[p.model]?.name ?? p.model;
// Packages occupying a plane's slots — 'loaded' cargo plus 'expired' cargo, which
// keeps its slot until it is abandoned or the plane lands at its destination.
export function slotsUsedFor(state: StateSnapshot, planeId: string): number {
return state.packages.filter(
(p) => p.planeId === planeId && (p.status === 'loaded' || p.status === 'expired'),
).length;
}
// Planes that can load `pkg` right now: GROUNDED at the package's origin with a
// free slot (capacity from the snapshot catalog). The Load button is enabled iff
// this list is non-empty; exactly one entry loads directly, several show a picker.
export function loadEligiblePlanes(state: StateSnapshot, pkg: PackageState): PlaneState[] {
return state.planes.filter((p) => {
if (p.status !== 'available') return false;
if (p.locationIcao !== pkg.originIcao) return false;
const capacity = state.catalog.find((e) => e.model === p.model)?.capacity ?? 0;
return slotsUsedFor(state, p.id) < capacity;
});
}
// Load controls for one open package: a single Load button when exactly one plane
// is eligible (it acts on that plane directly), a small plane picker + Load when
// several are, and a disabled button with the reason otherwise. Each row owns its
// selection in local state and defaults to the "Select a plane…" placeholder —
// never preselecting — so rows stay visibly independent (v0.10.1 pattern).
function LoadControls({
pkg,
eligible,
busy,
onLoad,
}: {
pkg: PackageState;
eligible: PlaneState[];
busy: boolean;
onLoad: (planeId: string) => void;
}) {
const [selectedId, setSelectedId] = useState<string | null>(null);
// The user's pick while it stays eligible, else nothing (placeholder).
const effective = eligible.find((p) => p.id === selectedId) ?? null;
if (eligible.length === 1) {
return (
<button
type="button"
data-testid={`load-${pkg.id}`}
className="pane-btn"
disabled={busy}
onClick={() => onLoad(eligible[0].id)}
>
Load
</button>
);
}
return (
<div className="dispatch-controls">
<select
data-testid={`load-plane-${pkg.id}`}
aria-label={`Load plane for ${pkg.originName} to ${pkg.destName}`}
value={effective?.id ?? ''}
onChange={(e) => setSelectedId(e.target.value || null)}
>
<option value="">Select a plane…</option>
{eligible.map((p) => (
<option key={p.id} value={p.id}>
{planeName(p)} — {p.locationIcao}
</option>
))}
</select>
<button
type="button"
data-testid={`load-${pkg.id}`}
className="pane-btn"
disabled={busy || effective === null}
onClick={() => {
if (effective) onLoad(effective.id);
}}
>
Load
</button>
</div>
);
}
export default function RightPane({ state, loading, applySnapshot, onHover }: RightPaneProps) {
const [busy, setBusy] = useState(false);
// Inline error from a failed action — auto-clears on the next successful poll (the shell's
// snapshot prop changes every tick) or the next successful action.
const [actionError, setActionError] = useState<string | null>(null);
useEffect(() => {
if (state !== null) setActionError(null);
}, [state]);
// Market picker selections — same "effective selection" pattern as LoadControls: the
// user's pick while it stays valid, else a sensible default (first catalog entry / first plane).
const [buyModelId, setBuyModelId] = useState<string | null>(null);
const [sellPlaneId, setSellPlaneId] = useState<string | null>(null);
const [refuelPlaneId, setRefuelPlaneId] = useState<string | null>(null);
// POST an action endpoint. Every success returns the FULL post-action snapshot (v0.8.0
// convention) — pushed into the shared state immediately; a 4xx {error} body becomes the
// inline pane error and the stale UI stays until the next poll. Buttons are disabled while
// a request is in flight, so at most one action runs at a time.
const runAction = useCallback(
async (path: string, body?: unknown) => {
if (busy) return;
setBusy(true);
try {
const res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
cache: 'no-store',
});
const data = (await res.json()) as StateSnapshot & { error?: string };
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
setActionError(null);
applySnapshot(data);
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
},
[applySnapshot, busy],
);
if (loading && !state) {
return (
<aside data-testid="right-pane" className="pane right-pane">
<h2 className="pane-title">Jobs &amp; Market</h2>
<p data-testid="right-loading" className="pane-muted">Loading…</p>
</aside>
);
}
if (!state) return null;
const clockMs = state.simState.clockMs;
const open = state.packages.filter((p) => p.status === 'open');
// On board (v0.12.0): every plane that carries cargo or is mid-flight gets a block —
// its loaded/expired packages with fly-to-destination + abandon controls, and flying
// planes show their current leg.
const onboard = state.planes.filter(
(p) => p.status === 'flying' || slotsUsedFor(state, p.id) > 0,
);
const catalog = state.catalog;
const buyEntry = catalog.find((e) => e.model === buyModelId) ?? catalog[0] ?? null;
const grounded = state.planes.filter((p) => p.status === 'available');
const sellPlane = state.planes.find((p) => p.id === sellPlaneId && p.status === 'available') ?? grounded[0] ?? null;
const refuelable = grounded.filter((p) => p.fuel < p.maxFuel);
const refuelPlane =
state.planes.find((p) => p.id === refuelPlaneId && p.status === 'available' && p.fuel < p.maxFuel) ??
refuelable[0] ??
null;
// Previews computed client-side from the snapshot's catalog + economy constants (v0.10.0).
const sellEntry = sellPlane ? catalog.find((e) => e.model === sellPlane.model) : undefined;
const sellPayout = sellEntry ? Math.round(sellEntry.price * state.sellFraction * 100) / 100 : null;
const refuelCost = refuelPlane ? (refuelPlane.maxFuel - refuelPlane.fuel) * state.fuelPricePerL : null;
return (
<aside data-testid="right-pane" className="pane right-pane">
<h2 className="pane-title">Jobs &amp; Market</h2>
<section className="pane-section">
<h3 className="pane-heading">Packages</h3>
{open.length === 0 ? (
<p className="pane-muted">No open packages.</p>
) : (
<ul data-testid="open-packages" className="contract-list">
{open.map((pkg) => {
const eligible = loadEligiblePlanes(state, pkg);
return (
// ISS-002: hovering an open package highlights its two endpoint airports on the map.
<li
key={pkg.id}
data-testid={`open-package-${pkg.id}`}
className="contract-row"
onMouseEnter={() => onHover({ kind: 'package', originIcao: pkg.originIcao, destIcao: pkg.destIcao })}
onMouseLeave={() => onHover(null)}
>
<div className="contract-main">
<span className="contract-route">{pkg.originName} → {pkg.destName}</span>
{eligible.length > 0 ? (
<LoadControls
pkg={pkg}
eligible={eligible}
busy={busy}
onLoad={(planeId) => void runAction(`/api/packages/${pkg.id}/load`, { planeId })}
/>
) : (
<button type="button" data-testid={`load-${pkg.id}`} className="pane-btn" disabled>
Load
</button>
)}
</div>
<div className="contract-sub">
<span data-testid={`open-package-reward-${pkg.id}`}>{formatCash(pkg.reward)}</span>
<span>{pkg.cargoType} × {pkg.cargoCount}</span>
<span data-testid={`open-package-deadline-${pkg.id}`}>
{formatDeadlineRelative(pkg.deadlineSimTime, clockMs)}
</span>
</div>
</li>
);
})}
</ul>
)}
</section>
<section className="pane-section">
<h3 className="pane-heading">On board</h3>
{onboard.length === 0 ? (
<p className="pane-muted">Nothing on board.</p>
) : (
onboard.map((plane) => {
const cargo = state.packages.filter(
(pkg) => pkg.planeId === plane.id && (pkg.status === 'loaded' || pkg.status === 'expired'),
);
return (
// ISS-002: hovering the on-board block (including its cargo rows) highlights the plane.
// Handlers live only on the outer div — React's enter/leave don't fire when moving to a
// child, so this covers the whole block without flicker between sub-elements.
<div
key={plane.id}
data-testid={`onboard-${plane.id}`}
className="onboard-block"
onMouseEnter={() => onHover({ kind: 'plane', id: plane.id })}
onMouseLeave={() => onHover(null)}
>
<div className="contract-main">
<span className="plane-model">{planeName(plane)}</span>
{plane.status === 'flying' ? (
<>
<span className="plane-status status-flying">in flight</span>
{plane.destName && (
<span data-testid={`plane-leg-${plane.id}`} className="contract-route">
{plane.locationName} → {plane.destName}
</span>
)}
</>
) : (
<span className="plane-status status-available">{plane.locationIcao}</span>
)}
</div>
{cargo.map((pkg) => {
const penalty = abandonPenalty(pkg, clockMs);
return (
<div key={pkg.id} data-testid={`onboard-package-${pkg.id}`} className="contract-row">
<div className="contract-main">
<button
type="button"
data-testid={`refuel-for-${pkg.id}`}
className="pane-btn"
disabled={busy || plane.status !== 'available'}
onClick={() => void runAction(`/api/planes/${plane.id}/refuel`, { mode: 'forFlight', packageId: pkg.id })}
>
Refuel
</button>
<button
type="button"
data-testid={`fly-to-${pkg.id}`}
className="pane-btn"
disabled={busy || plane.status !== 'available'}
onClick={() => void runAction(`/api/planes/${plane.id}/fly`, { destIcao: pkg.destIcao })}
>
Fly to {pkg.destName}
</button>
<button
type="button"
data-testid={`abandon-${pkg.id}`}
className="pane-btn"
disabled={busy || plane.status !== 'available'}
onClick={() => void runAction(`/api/packages/${pkg.id}/abandon`)}
>
Abandon{' '}
<span data-testid={`abandon-penalty-${pkg.id}`}>−{formatCash(penalty)}</span>
</button>
</div>
<div className="contract-sub">
<span className="contract-route">{pkg.originName} → {pkg.destName}</span>
<span>{formatCash(pkg.reward)}</span>
{pkg.status === 'expired' ? (
<span data-testid={`onboard-expired-${pkg.id}`}>expired</span>
) : (
<span>{formatDeadlineRelative(pkg.deadlineSimTime, clockMs)}</span>
)}
</div>
</div>
);
})}
</div>
);
})
)}
</section>
<section className="pane-section">
<h3 className="pane-heading">Market</h3>
<div className="market-row">
<select
data-testid="market-buy-model"
aria-label="Plane to buy"
value={buyEntry?.model ?? ''}
onChange={(e) => setBuyModelId(e.target.value || null)}
>
{catalog.map((e) => (
<option key={e.model} value={e.model}>
{e.label}
</option>
))}
</select>
<span data-testid="market-buy-price" className="market-preview">
{buyEntry ? formatCash(buyEntry.price) : ''}
</span>
<button
type="button"
data-testid="market-buy"
className="pane-btn"
disabled={busy || buyEntry === null}
onClick={() => {
if (buyEntry) void runAction('/api/market/buy-plane', { model: buyEntry.model });
}}
>
Buy
</button>
</div>
<div className="market-row">
<select
data-testid="market-sell-plane"
aria-label="Plane to sell"
value={sellPlane?.id ?? ''}
onChange={(e) => setSellPlaneId(e.target.value || null)}
>
{grounded.map((p) => (
<option key={p.id} value={p.id}>
{planeName(p)} — {p.locationIcao}
</option>
))}
</select>
<span data-testid="market-sell-payout" className="market-preview">
{sellPayout !== null ? formatCash(sellPayout) : ''}
</span>
<button
type="button"
data-testid="market-sell"
className="pane-btn"
disabled={busy || sellPlane === null}
onClick={() => {
if (sellPlane) void runAction('/api/market/sell-plane', { planeId: sellPlane.id });
}}
>
Sell
</button>
</div>
<div className="market-row">
{refuelable.length === 0 ? (
<span data-testid="market-refuel-none" className="pane-muted">
All tanks full.
</span>
) : (
<>
<select
data-testid="market-refuel-plane"
aria-label="Plane to refuel"
value={refuelPlane?.id ?? ''}
onChange={(e) => setRefuelPlaneId(e.target.value || null)}
>
{refuelable.map((p) => (
<option key={p.id} value={p.id}>
{planeName(p)} — {Math.round(p.fuel)} / {p.maxFuel} L
</option>
))}
</select>
<span data-testid="market-refuel-cost" className="market-preview">
{refuelCost !== null ? formatCash(refuelCost) : ''}
</span>
</>
)}
<button
type="button"
data-testid="market-refuel"
className="pane-btn"
disabled={busy || refuelPlane === null}
onClick={() => {
if (refuelPlane) void runAction(`/api/planes/${refuelPlane.id}/refuel`);
}}
>
Refuel to full
</button>
</div>
</section>
{actionError && <p data-testid="right-error" className="pane-error">{actionError}</p>}
</aside>
);
}