@@ -51,6 +63,7 @@ function DispatchControls({
value={effective?.id ?? ''}
onChange={(e) => setSelectedId(e.target.value || null)}
>
+
Select a plane…
{planes.map((p) => {
const reason = dispatchReason(p, contract);
return (
diff --git a/components/right-pane.test.ts b/components/right-pane.test.ts
new file mode 100644
index 0000000..06ae7e7
--- /dev/null
+++ b/components/right-pane.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from 'vitest';
+import { dispatchSelection } from './RightPane';
+import type { ContractState, PlaneState } 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.
+
+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,
+};
+
+function plane(over: Partial
& Pick): PlaneState {
+ return {
+ model: 'atr72',
+ status: 'available',
+ fuel: 100,
+ maxFuel: 100,
+ locationIcao: 'EDDF',
+ locationName: 'Frankfurt Main',
+ contractId: null,
+ departureSimTime: null,
+ lat: 50,
+ lon: 8.5,
+ ...over,
+ };
+}
+
+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();
+ });
+
+ 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('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();
+ });
+
+ 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('unknown selection id resolves to the placeholder', () => {
+ const planes = [plane({ id: 'p1' })];
+ expect(dispatchSelection(planes, contract, 'no-such-plane')).toBeNull();
+ });
+});
diff --git a/e2e/right-pane.spec.ts b/e2e/right-pane.spec.ts
index 87b827e..4a2f0a8 100644
--- a/e2e/right-pane.spec.ts
+++ b/e2e/right-pane.spec.ts
@@ -261,3 +261,85 @@ test('market buy/sell render with price and payout previews from the catalog in
await expect(page.getByTestId('market-sell-payout')).toContainText(eur(payout));
await expect(page.getByTestId('market-sell')).toBeVisible();
});
+
+test('dispatch pickers are independent per contract — selecting one does not leak into the others (v0.10.1)', async ({ page }) => {
+ test.setTimeout(240_000); // two short flights at scale 60 plus UI steps
+
+ // Self-heal first: a previous interrupted run may have left plane-starter mid-flight or at
+ // another airport (same pattern as the dispatch-loop test above).
+ let body = await getState(page);
+ const starter0 = body.planes.find((p) => p.id === 'plane-starter');
+ expect(starter0).toBeDefined();
+ if (starter0!.status === 'flying') {
+ await postSim(page, { timeScale: SCALE });
+ await expect
+ .poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', {
+ timeout: 90_000,
+ message: 'waiting for an in-flight plane-starter to land',
+ })
+ .toBe(true);
+ body = await getState(page);
+ }
+ if (body.planes.find((p) => p.id === 'plane-starter')!.locationIcao !== HUB) {
+ const from = body.planes.find((p) => p.id === 'plane-starter')!.locationIcao;
+ await flyViaApi(page, from, HUB);
+ }
+
+ // Two hook contracts, both originating at EDDF on the shortest route.
+ const nearest = await nearestToHub(page);
+ const a = await createContract(page, HUB, nearest.icao);
+ const b = await createContract(page, HUB, nearest.icao);
+
+ await page.goto('/');
+ // Accept BOTH through the UI buttons.
+ for (const c of [a, b]) {
+ const row = page.locator(`li[data-testid="open-contract-${c.id}"]`);
+ await expect(row).toBeVisible({ timeout: 15_000 });
+ await page.getByTestId(`accept-${c.id}`).click();
+ }
+
+ const accA = page.getByTestId(`accepted-contract-${a.id}`);
+ const accB = page.getByTestId(`accepted-contract-${b.id}`);
+ await expect(accA).toBeVisible({ timeout: 10_000 });
+ await expect(accB).toBeVisible();
+
+ // Both dispatch pickers render with the placeholder default — nothing preselected (v0.10.1)…
+ const pickerA = page.getByTestId(`dispatch-plane-${a.id}`);
+ const pickerB = page.getByTestId(`dispatch-plane-${b.id}`);
+ await expect(pickerA).toBeVisible();
+ await expect(pickerB).toBeVisible();
+ await expect(pickerA).toHaveValue('');
+ await expect(pickerB).toHaveValue('');
+ await expect(pickerA.locator('option[value=""]')).toHaveText(/select a plane/i);
+
+ // …and the Dispatch buttons stay disabled until a valid plane is chosen.
+ await expect(page.getByTestId(`dispatch-${a.id}`)).toBeDisabled();
+ await expect(page.getByTestId(`dispatch-${b.id}`)).toBeDisabled();
+
+ // Select plane-starter in contract A's picker → contract B's picker must STILL show the
+ // placeholder (the v0.10.1 regression: selections used to appear shared across rows).
+ await pickerA.selectOption('plane-starter');
+ await expect(pickerA).toHaveValue('plane-starter');
+ await expect(pickerB).toHaveValue('');
+
+ // Dispatch A with the selected plane → it goes 'flying' and its row shows the in-flight…
+ await page.getByTestId(`dispatch-${a.id}`).click();
+ await expect(page.getByTestId(`contract-inflight-${a.id}`)).toBeVisible({ timeout: 10_000 });
+ body = await getState(page);
+ expect(body.planes.find((p) => p.id === 'plane-starter')!.status).toBe('flying');
+
+ // …indicator, while B remains accepted with its own independent picker on the placeholder.
+ await expect(accB).toBeVisible();
+ await expect(pickerB).toHaveValue('');
+ await expect(page.getByTestId(`dispatch-${b.id}`)).toBeDisabled();
+
+ // Restore: let A's flight finish and fly plane-starter home so later specs start clean.
+ await postSim(page, { timeScale: SCALE });
+ await expect
+ .poll(async () => {
+ const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
+ return !!p && p.status === 'available' && p.locationIcao === nearest.icao;
+ }, { timeout: 90_000, message: `waiting for arrival at ${nearest.icao} (scale 60)` })
+ .toBe(true);
+ await flyViaApi(page, nearest.icao, HUB);
+});
diff --git a/package.json b/package.json
index 6ce8c86..689409a 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "flight-ops",
- "version": "0.10.0",
+ "version": "0.10.1",
"private": true,
"scripts": {
"dev": "next dev -p 3000",