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:
+159
-102
@@ -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 & 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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user