feat: v0.12.0 — contracts → packages: load/fly/abandon gameplay (ATR 72-600 capacity 6, manual flight to any airport, time-scaled abandon penalty 15–60%), one-time DB migration preserving wallet/clock; new endpoints /api/packages/:id/load|abandon + /api/planes/:planeId/fly

This commit is contained in:
2026-09-18 11:02:56 +00:00
parent 0208673da7
commit ab2e9fa54a
49 changed files with 2424 additions and 1409 deletions
+159 -102
View File
@@ -1,8 +1,9 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import type { ContractState, PlaneState, StateSnapshot } from '@/lib/game/state';
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';
interface RightPaneProps {
@@ -15,75 +16,85 @@ interface RightPaneProps {
const planeName = (p: PlaneState): string => PLANE_TYPES[p.model]?.name ?? p.model;
// Why a plane cannot be dispatched on `contract` — null when it can (grounded at the origin).
function dispatchReason(p: PlaneState, contract: ContractState): string | null {
if (p.status !== 'available') return 'in flight';
if (p.locationIcao !== contract.originIcao) return `at ${p.locationIcao}`;
return null;
// 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;
}
// The plane a dispatch picker should act on for one accepted contract: the user's explicit pick
// while it stays eligible, else NOTHING. v0.10.1 removed the "auto-track first eligible" fallback —
// it made every row display the same plane by default (selections looked shared across contracts)
// and left no way to show "nothing chosen". null = placeholder shown + Dispatch disabled; a poll
// that makes the pick ineligible (plane flew / moved away) drops back to the placeholder too.
export function dispatchSelection(
planes: PlaneState[],
contract: ContractState,
selectedId: string | null,
): PlaneState | null {
if (selectedId === null) return null;
return planes.find((p) => p.id === selectedId && dispatchReason(p, contract) === null) ?? null;
// 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;
});
}
// Dispatch controls for one accepted, not-yet-dispatched contract: a plane picker where only
// grounded planes at the origin are enabled (others disabled with the reason), plus a dispatch
// button bound to the selection. Each row owns its selection in local state and defaults to the
// "Select a plane…" placeholder — never preselecting — so rows stay visibly independent and a
// dispatch can't fire on the wrong contract by accident.
function DispatchControls({
contract,
planes,
// 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,
onDispatch,
onLoad,
}: {
contract: ContractState;
planes: PlaneState[];
pkg: PackageState;
eligible: PlaneState[];
busy: boolean;
onDispatch: (planeId: string) => void;
onLoad: (planeId: string) => void;
}) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const effective = dispatchSelection(planes, contract, selectedId);
// 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={`dispatch-plane-${contract.id}`}
aria-label={`Dispatch plane for ${contract.originName} to ${contract.destName}`}
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>
{planes.map((p) => {
const reason = dispatchReason(p, contract);
return (
<option key={p.id} value={p.id} disabled={reason !== null}>
{planeName(p)}
{reason ? ` — ${reason}` : ''}
</option>
);
})}
{eligible.map((p) => (
<option key={p.id} value={p.id}>
{planeName(p)} — {p.locationIcao}
</option>
))}
</select>
<button
type="button"
data-testid={`dispatch-${contract.id}`}
data-testid={`load-${pkg.id}`}
className="pane-btn"
disabled={busy || effective === null}
onClick={() => {
if (effective) onDispatch(effective.id);
if (effective) onLoad(effective.id);
}}
>
Dispatch
Load
</button>
</div>
);
@@ -98,7 +109,7 @@ export default function RightPane({ state, loading, applySnapshot }: RightPanePr
if (state !== null) setActionError(null);
}, [state]);
// Market picker selections — same "effective selection" pattern as DispatchControls: the
// 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);
@@ -143,8 +154,14 @@ export default function RightPane({ state, loading, applySnapshot }: RightPanePr
if (!state) return null;
const clockMs = state.simState.clockMs;
const open = state.contracts.filter((c) => c.status === 'open');
const accepted = state.contracts.filter((c) => c.status === 'accepted');
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;
@@ -167,70 +184,110 @@ export default function RightPane({ state, loading, applySnapshot }: RightPanePr
<h2 className="pane-title">Jobs &amp; Market</h2>
<section className="pane-section">
<h3 className="pane-heading">Open contracts</h3>
<h3 className="pane-heading">Packages</h3>
{open.length === 0 ? (
<p className="pane-muted">No open contracts.</p>
<p className="pane-muted">No open packages.</p>
) : (
<ul data-testid="open-contracts" className="contract-list">
{open.map((c) => (
<li key={c.id} data-testid={`open-contract-${c.id}`} className="contract-row">
<div className="contract-main">
<span className="contract-route">{c.originName} → {c.destName}</span>
<button
type="button"
data-testid={`accept-${c.id}`}
className="pane-btn"
disabled={busy}
onClick={() => void runAction(`/api/contracts/${c.id}/accept`)}
>
Accept
</button>
</div>
<div className="contract-sub">
<span data-testid={`open-contract-reward-${c.id}`}>{formatCash(c.reward)}</span>
<span>{c.cargoType} × {c.cargoCount}</span>
<span data-testid={`open-contract-deadline-${c.id}`}>
{formatDeadlineRelative(c.deadlineSimTime, clockMs)}
</span>
</div>
</li>
))}
<ul data-testid="open-packages" className="contract-list">
{open.map((pkg) => {
const eligible = loadEligiblePlanes(state, pkg);
return (
<li key={pkg.id} data-testid={`open-package-${pkg.id}`} className="contract-row">
<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">Accepted</h3>
{accepted.length === 0 ? (
<p className="pane-muted">Nothing accepted yet.</p>
<h3 className="pane-heading">On board</h3>
{onboard.length === 0 ? (
<p className="pane-muted">Nothing on board.</p>
) : (
<ul data-testid="accepted-contracts" className="contract-list">
{accepted.map((c) => (
<li key={c.id} data-testid={`accepted-contract-${c.id}`} className="contract-row">
onboard.map((plane) => {
const cargo = state.packages.filter(
(pkg) => pkg.planeId === plane.id && (pkg.status === 'loaded' || pkg.status === 'expired'),
);
return (
<div key={plane.id} data-testid={`onboard-${plane.id}`} className="onboard-block">
<div className="contract-main">
<span className="contract-route">{c.originName} → {c.destName}</span>
{c.planeId ? (
<span data-testid={`contract-inflight-${c.id}`} className="plane-status status-flying">
in flight
</span>
) : null}
<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>
<div className="contract-sub">
<span>{formatCash(c.reward)}</span>
<span>{c.cargoType} × {c.cargoCount}</span>
<span>{formatDeadlineRelative(c.deadlineSimTime, clockMs)}</span>
</div>
{!c.planeId && (
<DispatchControls
contract={c}
planes={state.planes}
busy={busy}
onDispatch={(planeId) => void runAction(`/api/planes/${planeId}/dispatch`, { contractId: c.id })}
/>
)}
</li>
))}
</ul>
{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={`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>{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>