Files
2026-09-27 22:56:16 +00:00

376 lines
16 KiB
TypeScript
Raw Permalink 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 { useState } from 'react';
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
import { MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, PLANE_TYPES, repRate, SELL_FRACTION } from '@/lib/game/catalog';
import { guaranteedRangeKm } from '@/lib/sim/movement';
import { formatCargo, formatCash, formatDeadlineRelative, formatKm, formatSpecialLabel } 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;
});
}
// v1.11.0 (ISS-159): the open packages the market list shows — hidden while no GROUNDED plane sits at
// their origin ("jobs for planes that are in air" disappear until landing). Payload-blocked rows stay:
// a grounded plane at the origin keeps the row visible with its disabled Load button.
export function visibleOpenPackages(state: StateSnapshot): PackageState[] {
return state.packages.filter(
(p) => p.status === 'open' && state.planes.some((pl) => pl.status === 'available' && pl.locationIcao === p.originIcao),
);
}
// 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 &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 = visibleOpenPackages(state);
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;
// v0.38.0 (ISS-072): the buy row shows the EFFECTIVE price — same rep-scaled formula as the server's buyPlane.
const planeDiscountRate = repRate(state.account.reputation, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT);
const buyPrice = buyEntry ? Math.round(buyEntry.price * (1 - planeDiscountRate) * 100) / 100 : null;
return (
<aside data-testid="right-pane" className="pane right-pane">
<h2 className="pane-title">Jobs &amp; 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>
{/* v0.37.0 (ISS-073): special-status badge — rendered only when the package has one. */}
{pkg.special && (
<span data-testid={`package-special-${pkg.id}`} className="special-badge">
{formatSpecialLabel(pkg.special)}
</span>
)}
{/* v1.4.0 (ISS-112): carrier name — who is carrying this package; rendered only when set. */}
{pkg.carrierName && (
<span data-testid={`open-package-carrier-${pkg.id}`} className="owner-badge">
{pkg.carrierName}
</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>
{/* v0.48.0 (ISS-095): leg distance after the reward, e.g. "€37,408.00 215km". */}
<span data-testid={`open-package-distance-${pkg.id}`}>{formatKm(pkg.distanceKm)}</span>
{/* ISS-066 (v0.35.0): cargo shows as type + tonnage ("Mail 10.0t"), not "type × count". */}
<span data-testid={`open-package-cargo-${pkg.id}`}>{formatCargo(pkg.cargoType, pkg.weightKg)}</span>
{/* v0.65.0: open jobs expire on the WALL clock — live countdown from expiresRealMs;
re-renders every poll tick (~1 s) keep it current without a timer. */}
<span data-testid={`open-package-deadline-${pkg.id}`}>
{formatDeadlineRelative(pkg.expiresRealMs ?? pkg.deadlineSimTime, Date.now())}
</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">
{buyPrice !== null ? formatCash(buyPrice) : ''}
</span>
{/* v0.38.0 (ISS-072): rep discount hint — only when the account actually gets one. */}
{planeDiscountRate > 0 && (
<span data-testid="market-buy-discount" className="special-badge">−{Math.round(planeDiscountRate * 100)}%</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 GUARANTEED full-tank range —
worst-case headwind, so any leg within it is flyable in all weather (v0.51.0, ISS-099). */}
{[...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(guaranteedRangeKm(e.burnPerKm, e.emptyWeightKg, e.maxFuel))} km
</strong>
</div>
</div>
))}
</section>
)}
{actionError && <p data-testid="right-error" className="pane-error">{actionError}</p>}
</aside>
);
}