CI / build + unit + e2e (pull_request) Failing after 8m13s
- LoadControls preselects the first eligible plane so Load is one-click with N planes
- LeftPane queue testids scoped by plane id (queue-leg/cancel/add[-dest]-{plane.id})
— unscoped ids duplicated in the DOM with two on-board blocks
- new POST /api/test/add-cash hook (FLIGHT_TEST_HOOKS=1, default €1M) so e2e can
afford a second plane; returns the full state snapshot
- new serial spec e2e/two-planes.spec.ts: load, parallel fly + ISS-057 slow/resume,
refuel, relocate, per-plane queues with auto-chain, sell (6 tests)
- e2e/queue.spec.ts locators updated to the scoped testids
352 lines
14 KiB
TypeScript
352 lines
14 KiB
TypeScript
'use client';
|
||
|
||
import { useState } from 'react';
|
||
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
|
||
import { PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
|
||
import { maxRangeKm } from '@/lib/sim/movement';
|
||
import { formatCash, formatDeadlineRelative } from '@/lib/format';
|
||
import { useGameAction } from '@/lib/useGameAction';
|
||
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;
|
||
// ISS-008 (v0.17.0): report a failed action to the shell as an error toast — in ADDITION
|
||
// to the inline pane error, which stays (both surfaces by design).
|
||
onError?: (message: string) => void;
|
||
}
|
||
|
||
const planeName = (p: PlaneState): string => PLANE_TYPES[p.model]?.name ?? p.model;
|
||
|
||
// Cargo weight (kg) occupying a plane's payload — 'loaded' cargo plus 'expired'
|
||
// cargo, which keeps its space until it is abandoned or the plane lands at its
|
||
// destination.
|
||
export function cargoWeightFor(state: StateSnapshot, planeId: string): number {
|
||
return state.packages
|
||
.filter((p) => p.planeId === planeId && (p.status === 'loaded' || p.status === 'expired'))
|
||
.reduce((sum, p) => sum + p.weightKg, 0);
|
||
}
|
||
|
||
// Planes that can load `pkg` right now: GROUNDED at the package's origin with enough
|
||
// payload room for it (maxPayloadKg 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 maxPayloadKg = state.catalog.find((e) => e.model === p.model)?.maxPayloadKg ?? 0;
|
||
// Mirrors the server check in lib/game/actions.ts loadPackage.
|
||
return cargoWeightFor(state, p.id) + pkg.weightKg <= maxPayloadKg;
|
||
});
|
||
}
|
||
|
||
// 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; ISS-055 (v0.31.0): the multi-plane path PRESELECTS the
|
||
// first eligible plane so Load is one-click out of the box (matching the single-plane
|
||
// UX) while the picker still allows choosing a different plane.
|
||
function LoadControls({
|
||
pkg,
|
||
eligible,
|
||
busy,
|
||
onLoad,
|
||
}: {
|
||
pkg: PackageState;
|
||
eligible: PlaneState[];
|
||
busy: boolean;
|
||
onLoad: (planeId: string) => void;
|
||
}) {
|
||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||
// ISS-055 (v0.31.0): the user's pick while it stays eligible, else the FIRST eligible plane —
|
||
// Load is enabled and one-click whenever ≥1 plane qualifies; the picker still allows a different pick.
|
||
const effective = eligible.find((p) => p.id === selectedId) ?? eligible[0] ?? 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, onError }: RightPaneProps) {
|
||
// ISS-021 (v0.20.0): shared action runner — busy gate, inline pane error + shell toast on
|
||
// failure, full post-action snapshot pushed into the shared state on success.
|
||
const { runAction, busy, actionError } = useGameAction(state, applySnapshot, onError);
|
||
|
||
// 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);
|
||
|
||
// v0.28.0 (ISS-047): Jobs / Planes market tabs — the inactive tab's content unmounts.
|
||
const [activeTab, setActiveTab] = useState<'jobs' | 'planes'>('jobs');
|
||
|
||
// ISS-023 (v0.21.0): the Relocate row moved to the left pane — it belongs with everything else
|
||
// about our planes, not in the market. Its state + /api/airports fetch went with it.
|
||
// ISS-035 (v0.23.0): same for the Refuel-to-full row — its state + derived values moved to the
|
||
// left pane's "Refuel" section next to Relocate.
|
||
|
||
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');
|
||
|
||
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;
|
||
// 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;
|
||
|
||
return (
|
||
<aside data-testid="right-pane" className="pane right-pane">
|
||
<h2 className="pane-title">Jobs & Market</h2>
|
||
|
||
{/* v0.28.0 (ISS-047): Jobs / Planes market tabs — the inactive tab's content unmounts. */}
|
||
<div className="pane-tabs" role="tablist">
|
||
<button
|
||
type="button"
|
||
data-testid="tab-jobs"
|
||
role="tab"
|
||
aria-selected={activeTab === 'jobs'}
|
||
className="tab-btn"
|
||
onClick={() => setActiveTab('jobs')}
|
||
>
|
||
Jobs
|
||
</button>
|
||
<button
|
||
type="button"
|
||
data-testid="tab-planes-market"
|
||
role="tab"
|
||
aria-selected={activeTab === 'planes'}
|
||
className="tab-btn"
|
||
onClick={() => setActiveTab('planes')}
|
||
>
|
||
Planes market
|
||
</button>
|
||
</div>
|
||
|
||
{activeTab === 'jobs' && (
|
||
<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>
|
||
)}
|
||
{/* ISS-047 part B (v0.29.0): dismiss the job — it leaves the market permanently, no penalty. */}
|
||
<button
|
||
type="button"
|
||
data-testid={`ignore-${pkg.id}`}
|
||
className="pane-btn"
|
||
disabled={busy}
|
||
onClick={() => void runAction(`/api/packages/${pkg.id}/ignore`)}
|
||
>
|
||
Ignore
|
||
</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>
|
||
)}
|
||
|
||
{/* ISS-021 (v0.20.0): the On board section moved to the left pane (between Fleet and Time). */}
|
||
|
||
{activeTab === 'planes' && (
|
||
<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>
|
||
|
||
{/* ISS-023 (v0.21.0): the Relocate row moved to the left pane's "Relocate" section. */}
|
||
{/* ISS-035 (v0.23.0): the Refuel-to-full row moved to the left pane's "Refuel" section, next to Relocate. */}
|
||
|
||
{/* v0.28.0 (ISS-047): tech-spec panel per catalog type — progression order (price ascending).
|
||
All values come from the snapshot's catalog; max range is the weighted-model closed form. */}
|
||
{[...catalog].sort((a, b) => a.price - b.price).map((e) => (
|
||
<div key={e.model} className="plane-type-card" data-testid={`plane-type-${e.model}`}>
|
||
<h4 className="plane-type-name">{e.label}</h4>
|
||
<div className="stat-row">
|
||
<span>Buy</span>
|
||
<strong>{formatCash(e.price)}</strong>
|
||
</div>
|
||
<div className="stat-row">
|
||
<span>Sell</span>
|
||
<strong>{formatCash(Math.round(e.price * SELL_FRACTION * 100) / 100)}</strong>
|
||
</div>
|
||
<div className="stat-row">
|
||
<span>Payload</span>
|
||
<strong>{(e.maxPayloadKg / 1000).toFixed(1)}t</strong>
|
||
</div>
|
||
<div className="stat-row">
|
||
<span>Cruise speed</span>
|
||
<strong>{e.speedKmh} km/h</strong>
|
||
</div>
|
||
<div className="stat-row">
|
||
<span>Fuel burn</span>
|
||
<strong>{e.burnPerKm} L/km</strong>
|
||
</div>
|
||
<div className="stat-row">
|
||
<span>Max fuel</span>
|
||
<strong>{e.maxFuel} L</strong>
|
||
</div>
|
||
<div className="stat-row">
|
||
<span>Max range</span>
|
||
<strong data-testid={`plane-type-range-${e.model}`}>
|
||
{Math.round(maxRangeKm(e.burnPerKm, e.emptyWeightKg, e.maxFuel))} km
|
||
</strong>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</section>
|
||
)}
|
||
|
||
{actionError && <p data-testid="right-error" className="pane-error">{actionError}</p>}
|
||
</aside>
|
||
);
|
||
}
|