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:
+15
-7
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react';
|
||||
import type { ContractState, PlaneState, StateSnapshot } from '@/lib/game/state';
|
||||
import type { PlaneState, StateSnapshot } from '@/lib/game/state';
|
||||
import { PLANE_TYPES } from '@/lib/game/catalog';
|
||||
import { formatCash, formatFuel, formatSimClock } from '@/lib/format';
|
||||
import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale';
|
||||
@@ -19,14 +19,21 @@ interface LeftPaneProps {
|
||||
const SCALE_SLIDER_STEP = 1 / (TIME_SCALES.length - 1); // one doubling per keyboard-arrow step
|
||||
const SCALE_COMMIT_DEBOUNCE_MS = 250;
|
||||
|
||||
function planeLocation(p: PlaneState, contracts: ContractState[]): string {
|
||||
if (p.status === 'flying' && p.contractId) {
|
||||
const c = contracts.find((c) => c.id === p.contractId);
|
||||
if (c) return `${p.locationName} → ${c.destName}`;
|
||||
}
|
||||
// Flying planes show their current leg from the snapshot's dest fields (v0.12.0).
|
||||
function planeLocation(p: PlaneState): string {
|
||||
if (p.status === 'flying' && p.destName) return `${p.locationName} → ${p.destName}`;
|
||||
return p.locationName;
|
||||
}
|
||||
|
||||
// Cargo loadout "n/capacity pkgs" — expired packages still occupy their slot.
|
||||
function loadoutFor(state: StateSnapshot, p: PlaneState): string {
|
||||
const used = state.packages.filter(
|
||||
(pkg) => pkg.planeId === p.id && (pkg.status === 'loaded' || pkg.status === 'expired'),
|
||||
).length;
|
||||
const capacity = state.catalog.find((e) => e.model === p.model)?.capacity ?? PLANE_TYPES[p.model]?.capacity ?? 0;
|
||||
return `${used}/${capacity} pkgs`;
|
||||
}
|
||||
|
||||
export default function LeftPane({ state, loading, error, onRefresh }: LeftPaneProps) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
@@ -156,7 +163,8 @@ export default function LeftPane({ state, loading, error, onRefresh }: LeftPaneP
|
||||
</div>
|
||||
<div className="plane-sub">
|
||||
<span data-testid={`plane-fuel-${p.id}`}>{formatFuel(p.fuel, p.maxFuel)}</span>
|
||||
<span className="plane-loc">{planeLocation(p, state.contracts)}</span>
|
||||
<span data-testid={`plane-loadout-${p.id}`}>{loadoutFor(state, p)}</span>
|
||||
<span className="plane-loc">{planeLocation(p)}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
|
||||
+5
-19
@@ -20,13 +20,13 @@ export const MAX_ZOOM = 11;
|
||||
|
||||
const AIRPORTS_SOURCE_ID = 'airports';
|
||||
const AIRPORTS_LAYER_ID = 'airports-circles';
|
||||
// Dynamic game-state layers (v0.11.0): planes as circles, active contract routes as lines.
|
||||
// Dynamic game-state layers (v0.11.0, package model v0.12.0): planes as circles,
|
||||
// each FLYING plane's current leg (origin → dest_icao) as a line.
|
||||
const PLANES_SOURCE_ID = 'planes';
|
||||
const PLANES_GROUNDED_LAYER_ID = 'planes-grounded';
|
||||
const PLANES_FLYING_LAYER_ID = 'planes-flying';
|
||||
const ROUTES_SOURCE_ID = 'routes';
|
||||
const ROUTES_PENDING_LAYER_ID = 'routes-pending';
|
||||
const ROUTES_INFLIGHT_LAYER_ID = 'routes-inflight';
|
||||
const ROUTES_LEGS_LAYER_ID = 'routes-legs';
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
@@ -198,25 +198,11 @@ export default function FlightMap({ state }: MapProps) {
|
||||
data: routesGeoJson(stateRef.current, airportsIndexRef.current ?? new Map()),
|
||||
});
|
||||
}
|
||||
if (!map.getLayer(ROUTES_PENDING_LAYER_ID)) {
|
||||
if (!map.getLayer(ROUTES_LEGS_LAYER_ID)) {
|
||||
map.addLayer({
|
||||
id: ROUTES_PENDING_LAYER_ID,
|
||||
id: ROUTES_LEGS_LAYER_ID,
|
||||
type: 'line',
|
||||
source: ROUTES_SOURCE_ID,
|
||||
filter: ['==', ['get', 'kind'], 'pending'],
|
||||
paint: {
|
||||
'line-color': '#94a3b8', // muted slate — accepted but not yet dispatched
|
||||
'line-width': 2,
|
||||
'line-dasharray': [2, 2],
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!map.getLayer(ROUTES_INFLIGHT_LAYER_ID)) {
|
||||
map.addLayer({
|
||||
id: ROUTES_INFLIGHT_LAYER_ID,
|
||||
type: 'line',
|
||||
source: ROUTES_SOURCE_ID,
|
||||
filter: ['==', ['get', 'kind'], 'inflight'],
|
||||
paint: {
|
||||
'line-color': '#f59e0b', // bright amber — actively flying the leg
|
||||
'line-width': 3,
|
||||
|
||||
+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>
|
||||
|
||||
|
||||
@@ -1,26 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { dispatchSelection } from './RightPane';
|
||||
import type { ContractState, PlaneState } from '@/lib/game/state';
|
||||
import { loadEligiblePlanes, slotsUsedFor } from './RightPane';
|
||||
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
|
||||
|
||||
// v0.10.1 — the per-contract dispatch picker rule: a row's effective selection is the user's
|
||||
// explicit pick while it stays eligible (available AND at the contract origin), else NOTHING
|
||||
// (placeholder). The old "auto-track first eligible" fallback made every accepted-contract row
|
||||
// display the same plane, so selections looked shared across contracts.
|
||||
// v0.12.0 — the package-model picker rules: a plane can LOAD a package only while
|
||||
// it is GROUNDED at the package's origin with a free slot (capacity from the
|
||||
// snapshot catalog; expired packages still occupy theirs). The Load button acts
|
||||
// directly on the single eligible plane, or shows a picker when several qualify.
|
||||
|
||||
const contract: ContractState = {
|
||||
id: 'contract-a',
|
||||
originIcao: 'EDDF',
|
||||
originName: 'Frankfurt Main',
|
||||
destIcao: 'EDDM',
|
||||
destName: 'Munich',
|
||||
reward: 1000,
|
||||
cargoType: 'mail',
|
||||
cargoCount: 4,
|
||||
deadlineSimTime: 1_000_000,
|
||||
status: 'accepted',
|
||||
planeId: null,
|
||||
createdSimTime: 0,
|
||||
};
|
||||
const CATALOG = [
|
||||
{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, speedKmh: 510, capacity: 6 },
|
||||
];
|
||||
|
||||
function plane(over: Partial<PlaneState> & Pick<PlaneState, 'id'>): PlaneState {
|
||||
return {
|
||||
@@ -30,7 +19,8 @@ function plane(over: Partial<PlaneState> & Pick<PlaneState, 'id'>): PlaneState {
|
||||
maxFuel: 100,
|
||||
locationIcao: 'EDDF',
|
||||
locationName: 'Frankfurt Main',
|
||||
contractId: null,
|
||||
destIcao: null,
|
||||
destName: null,
|
||||
departureSimTime: null,
|
||||
lat: 50,
|
||||
lon: 8.5,
|
||||
@@ -38,29 +28,93 @@ function plane(over: Partial<PlaneState> & Pick<PlaneState, 'id'>): PlaneState {
|
||||
};
|
||||
}
|
||||
|
||||
describe('dispatchSelection (per-contract dispatch picker rule)', () => {
|
||||
it('defaults to the placeholder — no auto-selection of the first eligible plane', () => {
|
||||
const planes = [plane({ id: 'p1' }), plane({ id: 'p2' })]; // both eligible at EDDF
|
||||
expect(dispatchSelection(planes, contract, null)).toBeNull();
|
||||
function pkg(over: Partial<PackageState> & Pick<PackageState, 'id'>): PackageState {
|
||||
return {
|
||||
originIcao: 'EDDF',
|
||||
originName: 'Frankfurt Main',
|
||||
destIcao: 'LEMD',
|
||||
destName: 'Madrid Barajas',
|
||||
reward: 1000,
|
||||
cargoType: 'mail',
|
||||
cargoCount: 4,
|
||||
deadlineSimTime: 1_000_000,
|
||||
status: 'open',
|
||||
planeId: null,
|
||||
createdSimTime: 0,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function snap(planes: PlaneState[], packages: PackageState[] = []): StateSnapshot {
|
||||
return {
|
||||
account: { cash: 0, reputation: 0 },
|
||||
simState: { clockMs: 0, timeScale: 1, paused: false },
|
||||
planes,
|
||||
packages,
|
||||
catalog: CATALOG,
|
||||
fuelPricePerL: 0.8,
|
||||
sellFraction: 0.5,
|
||||
};
|
||||
}
|
||||
|
||||
describe('slotsUsedFor', () => {
|
||||
it('counts loaded AND expired packages bound to the plane (both occupy a slot)', () => {
|
||||
const s = snap(
|
||||
[plane({ id: 'p1' })],
|
||||
[
|
||||
pkg({ id: 'a', status: 'loaded', planeId: 'p1' }),
|
||||
pkg({ id: 'b', status: 'expired', planeId: 'p1' }),
|
||||
pkg({ id: 'c', status: 'open', planeId: null }), // not on any plane
|
||||
pkg({ id: 'd', status: 'delivered', planeId: 'p1' }), // terminal — slot long freed
|
||||
],
|
||||
);
|
||||
expect(slotsUsedFor(s, 'p1')).toBe(2);
|
||||
});
|
||||
|
||||
it('returns the user pick while it stays eligible (available AND at the origin)', () => {
|
||||
const planes = [plane({ id: 'p1' }), plane({ id: 'p2', locationIcao: 'EDDM' })];
|
||||
expect(dispatchSelection(planes, contract, 'p1')?.id).toBe('p1');
|
||||
it('is 0 for a plane with no bound cargo', () => {
|
||||
expect(slotsUsedFor(snap([plane({ id: 'p1' })]), 'p1')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('drops to the placeholder when a poll makes the pick ineligible (flew away)', () => {
|
||||
const planes = [plane({ id: 'p1', status: 'flying' })];
|
||||
expect(dispatchSelection(planes, contract, 'p1')).toBeNull();
|
||||
describe('loadEligiblePlanes (Load button eligibility)', () => {
|
||||
it('includes only grounded planes at the package origin with a free slot', () => {
|
||||
const s = snap([
|
||||
plane({ id: 'at-origin' }), // eligible
|
||||
plane({ id: 'elsewhere', locationIcao: 'LEMD' }), // wrong airport
|
||||
plane({ id: 'in-flight', status: 'flying', destIcao: 'LEMD', destName: 'Madrid Barajas' }), // flying
|
||||
]);
|
||||
expect(loadEligiblePlanes(s, pkg({ id: 'x' })).map((p) => p.id)).toEqual(['at-origin']);
|
||||
});
|
||||
|
||||
it('drops to the placeholder when a poll moves the pick off the origin', () => {
|
||||
const planes = [plane({ id: 'p1', locationIcao: 'EDDM' })];
|
||||
expect(dispatchSelection(planes, contract, 'p1')).toBeNull();
|
||||
it('excludes a plane whose slots are full (capacity from the catalog)', () => {
|
||||
const packages = Array.from({ length: 6 }, (_, i) =>
|
||||
pkg({ id: `fill-${i}`, status: 'loaded', planeId: 'full' }),
|
||||
);
|
||||
const s = snap([plane({ id: 'full' })], packages);
|
||||
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('unknown selection id resolves to the placeholder', () => {
|
||||
const planes = [plane({ id: 'p1' })];
|
||||
expect(dispatchSelection(planes, contract, 'no-such-plane')).toBeNull();
|
||||
it('a full plane with one slot freed by an expired package still counts as full', () => {
|
||||
const packages = [
|
||||
...Array.from({ length: 5 }, (_, i) => pkg({ id: `fill-${i}`, status: 'loaded', planeId: 'full' })),
|
||||
pkg({ id: 'exp', status: 'expired', planeId: 'full' }), // occupies its slot
|
||||
];
|
||||
const s = snap([plane({ id: 'full' })], packages);
|
||||
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns several planes when several qualify (picker case)', () => {
|
||||
const s = snap([plane({ id: 'a' }), plane({ id: 'b' })]);
|
||||
expect(loadEligiblePlanes(s, pkg({ id: 'x' })).map((p) => p.id).sort()).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('returns nothing when no plane is at the origin (Load stays disabled)', () => {
|
||||
const s = snap([plane({ id: 'far', locationIcao: 'LEMD' })]);
|
||||
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('a plane with an unknown model has no capacity → never eligible', () => {
|
||||
const s = snap([plane({ id: 'mystery', model: 'mystery' })]);
|
||||
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user