diff --git a/CHANGELOG.md b/CHANGELOG.md index 781b93c..cc76051 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## v0.28.0 — 2026-09-20 13:55 +- **Right pane reworked into tabs (ISS-047, part A).** The right pane now has a tab bar under the "Jobs & Market" heading with two tabs — `tab-jobs` ("Jobs", default-active) and `tab-planes-market` ("Planes market") (`components/RightPane.tsx`, new `.pane-tabs`/`.tab-btn` CSS in `app/globals.css`). The Jobs tab is the existing Packages section moved verbatim (content, testids and hover behavior unchanged); the Planes market tab holds the existing buy/sell rows (testids unchanged) plus a tech-spec panel per catalog type — one card (`plane-type-{model}`) with price, sell value (price × SELL_FRACTION), max payload in tonnes, cruise speed, fuel burn, max fuel and **max range**, ordered by price ascending. The inactive tab unmounts; the `cargoWeightFor`/`loadEligiblePlanes` exports are untouched +- **Five new plane types — progression ladder above the ATR starter (ISS-047).** CRJ-900 (€450k), Embraer E175 (€900k), Airbus A320-200 (€1.8M), Airbus A330-200 (€4.5M) and Boeing 777-300ER (€9M) added to `PLANE_TYPES` in `lib/game/catalog.ts` with the exact balance values from the issue (bigger tanks, faster airframes, more payload). Purely additive — everything downstream (buy validation, movement math, load validation, snapshot catalog) is data-driven via `PLANE_TYPES[model]`, so no endpoint or schema change; the OpenAPI spec and its e2e path list stay at twelve +- **Derived max range (ISS-047).** New pure `maxRangeKm(burnPerKm, emptyWeightKg, maxFuel)` in `lib/sim/movement.ts` — closed form of the weighted burn model solving `requiredFuelFor(d) = maxFuel`: `ln(1 + ρF/We)·(We + ρF)/(ρ·burnPerKm)` (reuses `FUEL_DENSITY_KG_PER_L`, guards We > 0, zero fuel → 0). ATR 72-600 full tank ≈ 4436 km. Display-only — the sim still does not model range exhaustion +- **Sell-plane cargo guard (ISS-047).** `sellPlane` (`lib/game/actions.ts`) now 409s with "cannot sell a plane carrying cargo" when the grounded plane has any `loaded`/`expired` packages bound to it — previously selling such a plane orphaned its cargo (packages stuck 'loaded' with a dangling planeId that could never deliver). Same query pattern as `loadPackage`; the last-plane and flying-plane guards are unchanged +- Tests: unit — new `lib/sim/movement.test.ts` block for `maxRangeKm` (ATR ≈ 4439 km ±5%, monotonic in tank size, inverse in burn rate, zero fuel → 0, We ≤ 0 throws), new `lib/game/catalog.test.ts` sanity file (positive specs + unique ids across all six types, atr72 is STARTER_MODEL), `lib/game/actions.test.ts` sellPlane gains a loaded-cargo 409 case (row left untouched). E2E — `e2e/right-pane.spec.ts`: the market test clicks `tab-planes-market` first and a new "Planes market tab lists all plane types with tech specs" test asserts six catalog entries via /api/state, the ATR card's name + range against an in-test mirror of `maxRangeKm`, and the Boeing 777 card with its €9M price; `e2e/toasts.spec.ts` clicks the tab before its market-buy flow; `e2e/panes.spec.ts` asserts both tab buttons render with Jobs default-active. No new endpoints — `lib/openapi.test.ts` untouched +- Verified: `tsc --noEmit` clean; unit suite green locally (318, was 311); `npx playwright test --list` = 46 tests in 13 files (was 45/13 — the new spec-panel test); e2e not run locally by design — CI verifies against an isolated stack + ## v0.27.1 — 2026-09-20 13:16 - Right pane widened from 280px to 360px to match the left pane; toast stack offset adjusted to stay flush with the map's right edge (ISS-046). diff --git a/app/globals.css b/app/globals.css index f5c45f5..aa8844a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -507,3 +507,41 @@ body { opacity: 0.5; cursor: default; } + +/* v0.28.0 (ISS-047) — right pane tabs (Jobs / Planes market) + per-type tech-spec cards */ +.pane-tabs { + display: flex; + gap: 6px; + margin-bottom: 12px; +} + +.tab-btn { + flex: 1; + font-size: 12px; + padding: 4px 8px; + border-radius: 4px; + border: 1px solid rgba(148, 163, 184, 0.4); + background: rgba(30, 41, 59, 0.9); + color: #e2e8f0; + cursor: pointer; +} + +.tab-btn[aria-selected='true'] { + background: rgba(56, 189, 248, 0.25); + border-color: rgba(56, 189, 248, 0.7); + color: #f8fafc; +} + +.plane-type-card { + background: rgba(30, 41, 59, 0.6); + border-radius: 6px; + padding: 8px; + margin-bottom: 8px; +} + +.plane-type-name { + margin: 0 0 6px; + font-size: 13px; + font-weight: 600; + color: #f1f5f9; +} diff --git a/components/RightPane.tsx b/components/RightPane.tsx index 03b32de..63c7d0f 100644 --- a/components/RightPane.tsx +++ b/components/RightPane.tsx @@ -2,7 +2,8 @@ import { useState } from 'react'; import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state'; -import { PLANE_TYPES } from '@/lib/game/catalog'; +import { PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog'; +import { maxRangeKm } from '@/lib/sim/movement'; import { formatCash, formatDeadlineRelative } from '@/lib/format'; import { useGameAction } from '@/lib/useGameAction'; import type { HoverTarget } from './hoverTypes'; @@ -119,6 +120,9 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr const [buyModelId, setBuyModelId] = useState(null); const [sellPlaneId, setSellPlaneId] = useState(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 @@ -150,6 +154,31 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr diff --git a/e2e/panes.spec.ts b/e2e/panes.spec.ts index b003b2c..788e86b 100644 --- a/e2e/panes.spec.ts +++ b/e2e/panes.spec.ts @@ -190,4 +190,9 @@ test('right pane placeholder exists with the Jobs & Market heading', async ({ pa const right = page.getByTestId('right-pane'); await expect(right).toBeVisible(); await expect(right).toContainText('Jobs & Market'); + + // v0.28.0 (ISS-047): the Jobs / Planes market tab bar renders once a snapshot lands; Jobs is default-active. + await expect(page.getByTestId('tab-jobs'), 'the Jobs tab button appears').toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId('tab-planes-market')).toBeVisible(); + await expect(page.getByTestId('tab-jobs')).toHaveAttribute('aria-selected', 'true'); }); diff --git a/e2e/right-pane.spec.ts b/e2e/right-pane.spec.ts index 2df7688..7d62091 100644 --- a/e2e/right-pane.spec.ts +++ b/e2e/right-pane.spec.ts @@ -151,6 +151,12 @@ function requiredFuelFor(burnPerKm: number, emptyWeightKg: number, maxFuel: numb return (emptyWeightKg / FUEL_DENSITY_KG_PER_L) * (Math.exp(k * FUEL_DENSITY_KG_PER_L * distanceKm) - 1); } +// Mirrors maxRangeKm in lib/sim/movement.ts (v0.28.0): full-tank range under the weighted model. +function maxRangeKm(burnPerKm: number, emptyWeightKg: number, maxFuel: number): number { + const rho = FUEL_DENSITY_KG_PER_L; + return Math.log(1 + (rho * maxFuel) / emptyWeightKg) * ((emptyWeightKg + rho * maxFuel) / (rho * burnPerKm)); +} + // Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route. async function createPackage(page: Page, fromIcao: string, toIcao: string, deadlineOffsetMs?: number): Promise { const created = await page.request.post('/api/test/create-package', { @@ -312,6 +318,9 @@ test('market buy/sell render with previews from the catalog in /api/state; left- expect(entry.maxPayloadKg).toBe(4_800); // ATR 72-600 max payload (lib/game/catalog.ts) await page.goto('/'); + // v0.28.0 (ISS-047): the market rows live in the Planes market tab — switch to it first. + await page.getByTestId('tab-planes-market').click(); + // Buy: the model picker lists the catalog entry and the price preview is its full price. const buyModel = page.getByTestId('market-buy-model'); await expect(buyModel).toBeVisible({ timeout: 15_000 }); @@ -425,3 +434,23 @@ test('on-board row Refuel tops up just enough for the package leg; left-pane but // Top the tank back up so later specs start with a full tank. expect((await page.request.post('/api/planes/plane-starter/refuel')).status()).toBe(200); }); + +test('Planes market tab lists all plane types with tech specs', async ({ page }) => { + await page.goto('/'); + const body = await getState(page); // authoritative catalog — v0.28.0 ships six types + expect(body.catalog.length).toBe(6); + + await page.getByTestId('tab-planes-market').click(); + + // ATR card: name + full-tank range from the same closed form as lib/sim/movement.ts (v0.28.0). + const atr = body.catalog.find((e) => e.model === 'atr72')!; + await expect(page.getByTestId('plane-type-atr72'), 'the ATR card appears').toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId('plane-type-atr72')).toContainText(atr.label); + const atrRange = Math.round(maxRangeKm(atr.burnPerKm, atr.emptyWeightKg, atr.maxFuel)); + await expect(page.getByTestId('plane-type-range-atr72')).toContainText(`${atrRange} km`); + + // Ladder sanity: the most expensive type (Boeing 777-300ER) is present with its €9M price. + const b777 = page.getByTestId('plane-type-b777'); + await expect(b777).toBeVisible(); + await expect(b777).toContainText(eur(9_000_000)); // lib/game/catalog.ts +}); diff --git a/e2e/toasts.spec.ts b/e2e/toasts.spec.ts index 5e8364d..3ffd46e 100644 --- a/e2e/toasts.spec.ts +++ b/e2e/toasts.spec.ts @@ -25,6 +25,9 @@ async function getState(page: Page): Promise { test('failed actions toast at the map right edge, clear of the right pane (inline error stays), and the Time section offers a notifications opt-in', async ({ page }) => { await page.goto('/'); + // v0.28.0 (ISS-047): the market rows live in the Planes market tab — switch to it first. + await page.getByTestId('tab-planes-market').click(); + const buyModel = page.getByTestId('market-buy-model'); await expect(buyModel, 'the market section renders once the first snapshot lands').toBeVisible({ timeout: 15_000 }); diff --git a/lib/game/actions.test.ts b/lib/game/actions.test.ts index d682787..2b3e805 100644 --- a/lib/game/actions.test.ts +++ b/lib/game/actions.test.ts @@ -440,6 +440,19 @@ describe('sellPlane', () => { await expectActionError(sellPlane(inst, 'plane-extra'), 409, /flying/); }); + it('rejects selling a grounded plane that still carries cargo with 409 (v0.28.0)', async () => { + const inst = await freshDb('sell-cargo.db'); + await inst.db.insert(schema.planes).values({ + id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel, + locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null, + }); + await insertOpenPackage(inst, 'p-loaded', { status: 'loaded', planeId: 'plane-extra' }); + + await expectActionError(sellPlane(inst, 'plane-extra'), 409, /carrying cargo/); + const ids = (await inst.db.select().from(schema.planes)).map((p) => p.id); + expect(ids).toContain('plane-extra'); // row untouched — the cargo stays bound to a real plane + }); + it('rejects selling the last remaining plane with 409', async () => { const inst = await freshDb('sell-last.db'); // only the starter exists await expectActionError(sellPlane(inst, 'plane-starter'), 409, /last remaining/); diff --git a/lib/game/actions.ts b/lib/game/actions.ts index 08c711e..55ad92a 100644 --- a/lib/game/actions.ts +++ b/lib/game/actions.ts @@ -255,6 +255,15 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be sold`); } + // v0.28.0: selling a plane that still carries cargo would orphan it — the packages stay + // 'loaded'/'expired' with a dangling planeId and could never deliver (same query pattern as loadPackage). + const carrying = await inst.db.select({ id: schema.packages.id }).from(schema.packages).where( + and(inArray(schema.packages.status, ['loaded', 'expired']), eq(schema.packages.planeId, plane.id)), + ); + if (carrying.length > 0) { + throw new GameActionError(409, `cannot sell a plane carrying cargo`); + } + const all = await inst.db.select().from(schema.planes); if (all.length <= 1) throw new GameActionError(409, 'cannot sell the last remaining plane'); diff --git a/lib/game/catalog.test.ts b/lib/game/catalog.test.ts new file mode 100644 index 0000000..5391ded --- /dev/null +++ b/lib/game/catalog.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { PLANE_TYPES, STARTER_MODEL } from './catalog'; + +// v0.28.0 (ISS-047) — catalog sanity: the progression ladder must stay well-formed as types are added. +describe('PLANE_TYPES', () => { + it('every entry has positive specs and a unique id', () => { + const ids = Object.keys(PLANE_TYPES); + expect(new Set(ids).size).toBe(ids.length); + for (const t of Object.values(PLANE_TYPES)) { + expect(t.id, `${t.name} id`).toBeTruthy(); + expect(t.price, `${t.name} price`).toBeGreaterThan(0); + expect(t.maxFuel, `${t.name} maxFuel`).toBeGreaterThan(0); + expect(t.burnPerKm, `${t.name} burnPerKm`).toBeGreaterThan(0); + expect(t.emptyWeightKg, `${t.name} emptyWeightKg`).toBeGreaterThan(0); + expect(t.speedKmh, `${t.name} speedKmh`).toBeGreaterThan(0); + expect(t.maxPayloadKg, `${t.name} maxPayloadKg`).toBeGreaterThan(0); + } + }); + + it('atr72 is the starter model', () => { + expect(STARTER_MODEL).toBe('atr72'); + expect(PLANE_TYPES[STARTER_MODEL]).toBeDefined(); + }); +}); diff --git a/lib/game/catalog.ts b/lib/game/catalog.ts index 26bc27e..a82e33e 100644 --- a/lib/game/catalog.ts +++ b/lib/game/catalog.ts @@ -37,6 +37,60 @@ export const PLANE_TYPES: Record = { // must stay below this so any single package fits alone; typical loads carry ~3–6 packages. maxPayloadKg: 4_800, }, + // Progression ladder above the ATR starter (v0.28.0, ISS-047): bigger tanks, faster airframes, + // higher prices — everything downstream is data-driven via PLANE_TYPES[model]. Values are + // balance-tunable; keep maxPayloadKg ≥ every cargo type at max count × 1.3 jitter so any + // single package still fits alone (same rule as the ATR above). + crj900: { + id: 'crj900', + name: 'CRJ-900', + maxFuel: 8_000, + burnPerKm: 2.1, + emptyWeightKg: 17_500, + speedKmh: 830, + price: 450_000, + maxPayloadKg: 6_500, + }, + e175: { + id: 'e175', + name: 'Embraer E175', + maxFuel: 9_500, + burnPerKm: 2.0, + emptyWeightKg: 23_000, + speedKmh: 840, + price: 900_000, + maxPayloadKg: 9_000, + }, + a320: { + id: 'a320', + name: 'Airbus A320-200', + maxFuel: 12_000, + burnPerKm: 2.2, + emptyWeightKg: 41_000, + speedKmh: 845, + price: 1_800_000, + maxPayloadKg: 18_000, + }, + a330: { + id: 'a330', + name: 'Airbus A330-200', + maxFuel: 20_000, + burnPerKm: 2.16, + emptyWeightKg: 95_000, + speedKmh: 910, + price: 4_500_000, + maxPayloadKg: 48_000, + }, + b777: { + id: 'b777', + name: 'Boeing 777-300ER', + maxFuel: 30_000, + burnPerKm: 2.5, + emptyWeightKg: 130_000, + speedKmh: 920, + price: 9_000_000, + maxPayloadKg: 80_000, + }, }; // The plane the player starts with. diff --git a/lib/sim/movement.test.ts b/lib/sim/movement.test.ts index f5f88f5..abed4b8 100644 --- a/lib/sim/movement.test.ts +++ b/lib/sim/movement.test.ts @@ -5,6 +5,7 @@ import { burnRateConstant, FUEL_DENSITY_KG_PER_L, fuelAtWeighted, + maxRangeKm, positionAt, progressAt, requiredFuelFor, @@ -164,3 +165,24 @@ describe('weighted burn model (v0.16.0)', () => { expect(fuelAtWeighted(TANK, BURN, WE, TANK, KM, progressAt(DEP, KM, SPEED, clock))).toBeGreaterThan(0); }); }); + +describe('maxRangeKm (v0.28.0)', () => { + it('ATR 72-600 full tank ≈ 4439 km (±5%)', () => { + const r = maxRangeKm(BURN, WE, TANK); + expect(r).toBeGreaterThan(4439 * 0.95); + expect(r).toBeLessThan(4439 * 1.05); + }); + + it('increases with tank size and decreases with burn rate', () => { + expect(maxRangeKm(BURN, WE, TANK * 2)).toBeGreaterThan(maxRangeKm(BURN, WE, TANK)); + expect(maxRangeKm(3, WE, TANK)).toBeLessThan(maxRangeKm(BURN, WE, TANK)); + }); + + it('zero fuel → zero range', () => { + expect(maxRangeKm(BURN, WE, 0)).toBe(0); + }); + + it('rejects a non-positive empty weight', () => { + expect(() => maxRangeKm(BURN, 0, TANK)).toThrow(); + }); +}); diff --git a/lib/sim/movement.ts b/lib/sim/movement.ts index 2f0cd13..49dd77c 100644 --- a/lib/sim/movement.ts +++ b/lib/sim/movement.ts @@ -118,3 +118,13 @@ export function requiredFuelFor( const rho = FUEL_DENSITY_KG_PER_L; return (emptyWeightKg / rho) * (Math.exp(k * rho * distanceKm) - 1); } + +// Max range from a FULL tank under the weighted model — solve requiredFuelFor(d) = maxFuel for d: +// (We/ρ)(e^{kρd} − 1) = F → d = ln(1 + ρF/We) · (We + ρF)/(ρ·burnPerKm). +// Returns raw km (call sites round); zero fuel gives exactly 0. Pure — display-only, the sim +// itself does not model range exhaustion (see fuelAtWeighted). +export function maxRangeKm(burnPerKm: number, emptyWeightKg: number, maxFuel: number): number { + if (!(emptyWeightKg > 0)) throw new Error('emptyWeightKg must be > 0'); + const rho = FUEL_DENSITY_KG_PER_L; + return Math.log(1 + (rho * maxFuel) / emptyWeightKg) * ((emptyWeightKg + rho * maxFuel) / (rho * burnPerKm)); +} diff --git a/package.json b/package.json index 8613b47..1900acb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "0.27.1", + "version": "0.28.0", "private": true, "scripts": { "dev": "next dev -p 3000",