CI / build + unit + e2e (pull_request) Failing after 5m28s
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.
426 lines
17 KiB
TypeScript
426 lines
17 KiB
TypeScript
'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 & 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 & 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>
|
||
);
|
||
}
|