Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d8e1ad35e | ||
|
|
0ac133f750 | ||
|
|
d3038b3c7f | ||
|
|
847e74b7b8 | ||
|
|
394d51d827 | ||
|
|
264778522c | ||
|
|
e55078fb5d |
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.35.0 — 2026-09-21 02:26
|
||||||
|
- **Job list shows cargo tonnage instead of count (ISS-066).** The open-package rows in the right pane's Jobs tab (`components/RightPane.tsx`) now show cargo as type + tonnage ("Mail 10.0t") via a new `formatCargo` in `lib/format.ts` — `(kg / 1000).toFixed(1) + 't'`, identical to the loadout display — instead of "type × count"; unit tests added to `lib/format.test.ts`; e2e assertion added to `e2e/right-pane.spec.ts` (the hook-created package is forced to 10 t and its row must contain "10.0t"). No server or OpenAPI changes — `weightKg` was already in the snapshot
|
||||||
|
|
||||||
|
## v0.34.0 — 2026-09-21 01:26
|
||||||
|
- **Duplicate queue legs rejected with 409 (ISS-065).** `queueLeg` (`lib/game/actions.ts`) now refuses to append a destination that is already in the plane's flight queue ("{icao} is already in the flight queue"), and — while the plane is flying — re-queuing the active leg's destination ("already flying to {icao}"). Previously the same route could be queued any number of times, producing pointless repeated/zero-length hops when landing auto-chains `queue[0]`. No UI or OpenAPI changes (the endpoint already documents error responses)
|
||||||
|
|
||||||
|
## v0.33.0 — 2026-09-21 00:45
|
||||||
|
- **Merged On board into the Fleet list (ISS-054).** The left pane's separate "On board" section is deleted; each Fleet row (`components/LeftPane.tsx`) is now the single per-plane block — status/fuel/loadout plus, when present, that plane's loaded/expired cargo rows (fly-to / refuel-for-flight / abandon + penalty preview) and its queue legs. The header shows "in flight" + the current leg for flying planes and the location ICAO when grounded; the empty "Queue" heading is no longer rendered (the sub-section renders only with ≥1 queued leg). All per-item testids unchanged (`onboard-package-*`, `fly-to-*`, `refuel-for-*`, `abandon-*`, `abandon-penalty-*`, `plane-leg-*`, `queue-leg-*`, `queue-cancel-*`); the removed one is `onboard-{plane.id}` (use `plane-{id}`). CSS: `.onboard-block .contract-row*` re-targeted to `.plane-row .contract-row*`; no server or OpenAPI changes
|
||||||
|
- **E2E updates.** The four `onboard-{plane.id}` references now target the fleet rows (`e2e/hover.spec.ts`, `e2e/right-pane.spec.ts`, `e2e/two-planes.spec.ts`); `e2e/panes.spec.ts` no longer asserts an "On board" heading — it asserts the heading is absent from both panes
|
||||||
|
|
||||||
## v0.32.0 — 2026-09-20 23:47
|
## v0.32.0 — 2026-09-20 23:47
|
||||||
- **Queue via fly-to and Relocate; dedicated queue-add input removed (ISS-053).** The flight queue no longer has its own entry point in the On board block (`components/LeftPane.tsx`): the per-cargo "Fly to …" button is now enabled for ANY plane state, and posts to `/api/planes/{id}/queue` instead of `/fly` when the plane is `flying` (grounded → `/fly`, unchanged); Relocate's plane list widened from grounded-only to ALL planes (`relocatable = state.planes`; Refuel keeps its own `grounded` filter), and `relocate-go` posts to `/queue` when the SELECTED plane is flying, else `/fly`. The queue-add select + "Add to queue" button (and the shared `queueAddDestIcao` state) are deleted; leg rows + cancel buttons unchanged. No server or OpenAPI changes — both endpoints already existed
|
- **Queue via fly-to and Relocate; dedicated queue-add input removed (ISS-053).** The flight queue no longer has its own entry point in the On board block (`components/LeftPane.tsx`): the per-cargo "Fly to …" button is now enabled for ANY plane state, and posts to `/api/planes/{id}/queue` instead of `/fly` when the plane is `flying` (grounded → `/fly`, unchanged); Relocate's plane list widened from grounded-only to ALL planes (`relocatable = state.planes`; Refuel keeps its own `grounded` filter), and `relocate-go` posts to `/queue` when the SELECTED plane is flying, else `/fly`. The queue-add select + "Add to queue" button (and the shared `queueAddDestIcao` state) are deleted; leg rows + cancel buttons unchanged. No server or OpenAPI changes — both endpoints already existed
|
||||||
- **E2E updates.** `e2e/queue.spec.ts` test 1 now adds its queued leg via the Relocate section (selects plane-starter, which is flying at that point → `/queue`) instead of the removed queue-add control; `e2e/two-planes.spec.ts` "per-plane queues are independent" adds the starter's second leg via API POST `/api/planes/plane-starter/queue` (the starter is grounded there, so fly-to would start rather than queue). All `queue-leg-*` / `queue-cancel-*` assertions unchanged
|
- **E2E updates.** `e2e/queue.spec.ts` test 1 now adds its queued leg via the Relocate section (selects plane-starter, which is flying at that point → `/queue`) instead of the removed queue-add control; `e2e/two-planes.spec.ts` "per-plane queues are independent" adds the starter's second leg via API POST `/api/planes/plane-starter/queue` (the starter is grounded there, so fly-to would start rather than queue). All `queue-leg-*` / `queue-cancel-*` assertions unchanged
|
||||||
|
|||||||
+5
-12
@@ -376,17 +376,10 @@ body {
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* v0.12.0 — on-board blocks (per plane); moved to the left pane in ISS-021 (v0.20.0) */
|
/* ISS-054 (v0.33.0) — per-plane cargo rows nested inside the Fleet list's plane cards: two lines,
|
||||||
.onboard-block {
|
a full-width fly-to button then refuel + abandon side by side below it; long destination names
|
||||||
background: rgba(30, 41, 59, 0.6);
|
truncate with an ellipsis. Transparent sub-rows with a top border under the card header. */
|
||||||
border-radius: 6px;
|
.plane-row .contract-row {
|
||||||
padding: 8px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ISS-028 (v0.20.0 part 2) — on-board cargo rows are two lines: a full-width fly-to button,
|
|
||||||
then refuel + abandon side by side below it; long destination names truncate with an ellipsis. */
|
|
||||||
.onboard-block .contract-row {
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 6px 0 0;
|
padding: 6px 0 0;
|
||||||
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
@@ -395,7 +388,7 @@ body {
|
|||||||
gap: 5px;
|
gap: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboard-block .contract-row .contract-sub {
|
.plane-row .contract-row .contract-sub {
|
||||||
margin-top: 0; /* row spacing comes from the container's gap */
|
margin-top: 0; /* row spacing comes from the container's gap */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+47
-72
@@ -19,7 +19,7 @@ interface LeftPaneProps {
|
|||||||
// The shell's single-poll refresh — called after a sim-control POST so the pane updates immediately.
|
// The shell's single-poll refresh — called after a sim-control POST so the pane updates immediately.
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
// ISS-021 (v0.20.0): push the full snapshot returned by an action endpoint into the shared shell
|
// ISS-021 (v0.20.0): push the full snapshot returned by an action endpoint into the shared shell
|
||||||
// state — needed now that the On board section's refuel/fly/abandon actions live in this pane.
|
// state — needed now that the per-plane cargo rows' refuel/fly/abandon actions live in this pane.
|
||||||
applySnapshot: (snapshot: StateSnapshot) => void;
|
applySnapshot: (snapshot: StateSnapshot) => void;
|
||||||
// ISS-002 (v0.15.0): report fleet-row hovers to the shell so the map can highlight the plane.
|
// ISS-002 (v0.15.0): report fleet-row hovers to the shell so the map can highlight the plane.
|
||||||
onHover: (target: HoverTarget | null) => void;
|
onHover: (target: HoverTarget | null) => void;
|
||||||
@@ -92,7 +92,7 @@ export async function retrySimPost(
|
|||||||
export default function LeftPane({ state, loading, error, onRefresh, applySnapshot, onHover, onError }: LeftPaneProps) {
|
export default function LeftPane({ state, loading, error, onRefresh, applySnapshot, onHover, onError }: LeftPaneProps) {
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
// ISS-021 (v0.20.0): shared action runner for the On board section (moved here from RightPane).
|
// ISS-021 (v0.20.0): shared action runner for the per-plane cargo/queue actions (moved here from RightPane).
|
||||||
const { runAction, busy: actionsBusy, actionError } = useGameAction(state, applySnapshot, onError);
|
const { runAction, busy: actionsBusy, actionError } = useGameAction(state, applySnapshot, onError);
|
||||||
|
|
||||||
// ISS-023 (v0.21.0): Relocate selections — moved here from the right-pane market row; same
|
// ISS-023 (v0.21.0): Relocate selections — moved here from the right-pane market row; same
|
||||||
@@ -195,15 +195,8 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
const paused = state?.simState.paused ?? false;
|
const paused = state?.simState.paused ?? false;
|
||||||
const timeScale = state?.simState.timeScale ?? 1;
|
const timeScale = state?.simState.timeScale ?? 1;
|
||||||
|
|
||||||
// On board (v0.12.0, moved from the right pane in ISS-021 v0.20.0): every plane that carries
|
// Snapshot clock — feeds the deadline/penalty previews in the per-plane cargo rows.
|
||||||
// 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 clockMs = state?.simState.clockMs ?? 0;
|
const clockMs = state?.simState.clockMs ?? 0;
|
||||||
// ISS-043 (v0.26.0): a plane with queued legs always shows its block too — the queue is the
|
|
||||||
// reason it's here even when grounded with nothing on board.
|
|
||||||
const onboard = state
|
|
||||||
? state.planes.filter((p) => p.status === 'flying' || cargoWeightFor(state, p.id) > 0 || (p.queue?.length ?? 0) > 0)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// ISS-023 (v0.21.0): Relocate — any plane can be repositioned to any airport via the existing fly
|
// ISS-023 (v0.21.0): Relocate — any plane can be repositioned to any airport via the existing fly
|
||||||
// endpoint (pure repositioning, no package needed since v0.12.0). Moved here from the right-pane
|
// endpoint (pure repositioning, no package needed since v0.12.0). Moved here from the right-pane
|
||||||
@@ -340,8 +333,17 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
<p className="pane-muted">No planes.</p>
|
<p className="pane-muted">No planes.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul data-testid="fleet-list" className="fleet-list">
|
<ul data-testid="fleet-list" className="fleet-list">
|
||||||
{state.planes.map((p) => (
|
{state.planes.map((p) => {
|
||||||
// ISS-002: hovering a fleet row highlights the plane on the map.
|
// ISS-054 (v0.33.0): the merged per-plane block — status/fuel/loadout plus the former
|
||||||
|
// On board section's cargo rows and queue legs, so all plane info lives in one place.
|
||||||
|
const cargo = state.packages.filter(
|
||||||
|
(pkg) => pkg.planeId === p.id && (pkg.status === 'loaded' || pkg.status === 'expired'),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
// ISS-002: hovering a fleet row highlights the plane on the map. Handlers live only on
|
||||||
|
// the outer <li> — React's enter/leave don't fire when moving to a child, so this covers
|
||||||
|
// the whole block without flicker between sub-elements; cargo rows override with their
|
||||||
|
// own package hover (below).
|
||||||
<li
|
<li
|
||||||
key={p.id}
|
key={p.id}
|
||||||
data-testid={`plane-${p.id}`}
|
data-testid={`plane-${p.id}`}
|
||||||
@@ -351,7 +353,18 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
>
|
>
|
||||||
<div className="plane-main">
|
<div className="plane-main">
|
||||||
<span className="plane-model">{PLANE_TYPES[p.model]?.name ?? p.model}</span>
|
<span className="plane-model">{PLANE_TYPES[p.model]?.name ?? p.model}</span>
|
||||||
<span className={`plane-status status-${p.status}`}>{p.status}</span>
|
{p.status === 'flying' ? (
|
||||||
|
<>
|
||||||
|
<span className="plane-status status-flying">in flight</span>
|
||||||
|
{p.destName && (
|
||||||
|
<span data-testid={`plane-leg-${p.id}`} className="contract-route">
|
||||||
|
{p.locationName} → {p.destName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="plane-status status-available">{p.locationIcao}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="fuel-bar" aria-hidden="true">
|
<div className="fuel-bar" aria-hidden="true">
|
||||||
<div style={{ width: `${Math.max(0, Math.min(100, (p.fuel / p.maxFuel) * 100))}%` }} />
|
<div style={{ width: `${Math.max(0, Math.min(100, (p.fuel / p.maxFuel) * 100))}%` }} />
|
||||||
@@ -361,48 +374,6 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
<span data-testid={`plane-loadout-${p.id}`}>{loadoutFor(state, p)}</span>
|
<span data-testid={`plane-loadout-${p.id}`}>{loadoutFor(state, p)}</span>
|
||||||
<span className="plane-loc">{planeLocation(p)}</span>
|
<span className="plane-loc">{planeLocation(p)}</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ISS-021 (v0.20.0): moved here from the right pane — between Fleet and Time. */}
|
|
||||||
<section className="pane-section">
|
|
||||||
<h3 className="pane-heading">On board</h3>
|
|
||||||
{onboard.length === 0 ? (
|
|
||||||
<p className="pane-muted">Nothing on board.</p>
|
|
||||||
) : (
|
|
||||||
onboard.map((plane) => {
|
|
||||||
const cargo = state.packages.filter(
|
|
||||||
(pkg) => pkg.planeId === plane.id && (pkg.status === 'loaded' || pkg.status === 'expired'),
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
// ISS-002: hovering the on-board block (including its cargo rows) highlights the plane.
|
|
||||||
// Handlers live only on the outer div — React's enter/leave don't fire when moving to a
|
|
||||||
// child, so this covers the whole block without flicker between sub-elements.
|
|
||||||
<div
|
|
||||||
key={plane.id}
|
|
||||||
data-testid={`onboard-${plane.id}`}
|
|
||||||
className="onboard-block"
|
|
||||||
onMouseEnter={() => onHover({ kind: 'plane', id: plane.id })}
|
|
||||||
onMouseLeave={() => onHover(null)}
|
|
||||||
>
|
|
||||||
<div className="contract-main">
|
|
||||||
<span className="plane-model">{PLANE_TYPES[plane.model]?.name ?? plane.model}</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>
|
|
||||||
{cargo.map((pkg) => {
|
{cargo.map((pkg) => {
|
||||||
const penalty = abandonPenalty(pkg, clockMs);
|
const penalty = abandonPenalty(pkg, clockMs);
|
||||||
return (
|
return (
|
||||||
@@ -414,8 +385,8 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
key={pkg.id}
|
key={pkg.id}
|
||||||
data-testid={`onboard-package-${pkg.id}`}
|
data-testid={`onboard-package-${pkg.id}`}
|
||||||
className="contract-row"
|
className="contract-row"
|
||||||
onMouseEnter={() => onHover({ kind: 'package', originIcao: pkg.originIcao, destIcao: pkg.destIcao, planeId: plane.id })}
|
onMouseEnter={() => onHover({ kind: 'package', originIcao: pkg.originIcao, destIcao: pkg.destIcao, planeId: p.id })}
|
||||||
onMouseLeave={() => onHover({ kind: 'plane', id: plane.id })}
|
onMouseLeave={() => onHover({ kind: 'plane', id: p.id })}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -424,7 +395,7 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
disabled={actionsBusy}
|
disabled={actionsBusy}
|
||||||
// ISS-053 (v0.32.0): a flying plane can't start another leg — the click queues it instead,
|
// ISS-053 (v0.32.0): a flying plane can't start another leg — the click queues it instead,
|
||||||
// so "Fly to …" works from any state and the queue needs no dedicated input.
|
// so "Fly to …" works from any state and the queue needs no dedicated input.
|
||||||
onClick={() => void runAction(`/api/planes/${plane.id}/${plane.status === 'flying' ? 'queue' : 'fly'}`, { destIcao: pkg.destIcao })}
|
onClick={() => void runAction(`/api/planes/${p.id}/${p.status === 'flying' ? 'queue' : 'fly'}`, { destIcao: pkg.destIcao })}
|
||||||
>
|
>
|
||||||
<span className="onboard-fly-label" title={`Fly to ${pkg.destName}`}>
|
<span className="onboard-fly-label" title={`Fly to ${pkg.destName}`}>
|
||||||
Fly to {pkg.destName}
|
Fly to {pkg.destName}
|
||||||
@@ -435,8 +406,8 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
type="button"
|
type="button"
|
||||||
data-testid={`refuel-for-${pkg.id}`}
|
data-testid={`refuel-for-${pkg.id}`}
|
||||||
className="pane-btn"
|
className="pane-btn"
|
||||||
disabled={actionsBusy || plane.status !== 'available'}
|
disabled={actionsBusy || p.status !== 'available'}
|
||||||
onClick={() => void runAction(`/api/planes/${plane.id}/refuel`, { mode: 'forFlight', packageId: pkg.id })}
|
onClick={() => void runAction(`/api/planes/${p.id}/refuel`, { mode: 'forFlight', packageId: pkg.id })}
|
||||||
>
|
>
|
||||||
Refuel
|
Refuel
|
||||||
</button>
|
</button>
|
||||||
@@ -444,7 +415,7 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
type="button"
|
type="button"
|
||||||
data-testid={`abandon-${pkg.id}`}
|
data-testid={`abandon-${pkg.id}`}
|
||||||
className="pane-btn"
|
className="pane-btn"
|
||||||
disabled={actionsBusy || plane.status !== 'available'}
|
disabled={actionsBusy || p.status !== 'available'}
|
||||||
onClick={() => void runAction(`/api/packages/${pkg.id}/abandon`)}
|
onClick={() => void runAction(`/api/packages/${pkg.id}/abandon`)}
|
||||||
>
|
>
|
||||||
Abandon{' '}
|
Abandon{' '}
|
||||||
@@ -465,36 +436,40 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
})}
|
})}
|
||||||
{/* ISS-043 (v0.26.0): flight queue — the not-yet-started legs with cancel.
|
{/* ISS-043 (v0.26.0): flight queue — the not-yet-started legs with cancel.
|
||||||
ISS-053 (v0.32.0): the dedicated add input is gone — fly-to and Relocate post to
|
ISS-053 (v0.32.0): the dedicated add input is gone — fly-to and Relocate post to
|
||||||
/queue while a plane is flying, so no separate entry point is needed. */}
|
/queue while a plane is flying, so no separate entry point is needed.
|
||||||
|
ISS-054 (v0.33.0): rendered only when non-empty. */}
|
||||||
|
{p.queue.length > 0 && (
|
||||||
<div className="queue-subsection">
|
<div className="queue-subsection">
|
||||||
<span className="queue-heading">Queue</span>
|
<span className="queue-heading">Queue</span>
|
||||||
{/* ISS-055 (v0.31.0): testids scoped by plane id — with two on-board blocks the
|
{/* ISS-055 (v0.31.0): testids scoped by plane id — with two blocks the unscoped ids
|
||||||
unscoped ids duplicated in the DOM and broke Playwright strict mode. */}
|
duplicated in the DOM and broke Playwright strict mode. */}
|
||||||
{plane.queue.map((icao, i) => (
|
{p.queue.map((icao, i) => (
|
||||||
<div key={`${icao}-${i}`} data-testid={`queue-leg-${plane.id}-${i}`} className="queue-row">
|
<div key={`${icao}-${i}`} data-testid={`queue-leg-${p.id}-${i}`} className="queue-row">
|
||||||
<span className="contract-route">
|
<span className="contract-route">
|
||||||
{airportOptions?.find((a) => a.icao === icao)?.name ?? icao} ({icao})
|
{airportOptions?.find((a) => a.icao === icao)?.name ?? icao} ({icao})
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid={`queue-cancel-${plane.id}-${i}`}
|
data-testid={`queue-cancel-${p.id}-${i}`}
|
||||||
className="pane-btn"
|
className="pane-btn"
|
||||||
disabled={actionsBusy}
|
disabled={actionsBusy}
|
||||||
onClick={() => void runAction(`/api/planes/${plane.id}/queue/cancel`, { index: i })}
|
onClick={() => void runAction(`/api/planes/${p.id}/queue/cancel`, { index: i })}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
</li>
|
||||||
);
|
);
|
||||||
})
|
})}
|
||||||
|
</ul>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ISS-023 (v0.21.0): moved here from the right-pane market row — it belongs with
|
{/* ISS-023 (v0.21.0): moved here from the right-pane market row — it belongs with
|
||||||
everything else about our planes, between On board and Time. */}
|
everything else about our planes, between Fleet and Time. */}
|
||||||
{/* ISS-027 (v0.22.0): hovering the section previews the planned relocation on the map —
|
{/* ISS-027 (v0.22.0): hovering the section previews the planned relocation on the map —
|
||||||
dashed trace from the selected plane's airport to the destination + a ring on the target.
|
dashed trace from the selected plane's airport to the destination + a ring on the target.
|
||||||
With no planes or no loaded airport options there is nothing to preview → null. */}
|
With no planes or no loaded airport options there is nothing to preview → null. */}
|
||||||
@@ -562,7 +537,7 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
<section className="pane-section" data-testid="refuel-section">
|
<section className="pane-section" data-testid="refuel-section">
|
||||||
<h3 className="pane-heading">Refuel</h3>
|
<h3 className="pane-heading">Refuel</h3>
|
||||||
{/* Moved verbatim from the right-pane Market section — same testids/labels; the per-package
|
{/* Moved verbatim from the right-pane Market section — same testids/labels; the per-package
|
||||||
"Refuel" button in On board (mode 'forFlight') is a different feature and stays there. */}
|
"Refuel" button in the fleet rows (mode 'forFlight') is a different feature and stays there. */}
|
||||||
<div className="market-row">
|
<div className="market-row">
|
||||||
{refuelable.length === 0 ? (
|
{refuelable.length === 0 ? (
|
||||||
<span data-testid="market-refuel-none" className="pane-muted">
|
<span data-testid="market-refuel-none" className="pane-muted">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState } from 'react';
|
|||||||
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
|
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
|
||||||
import { PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
|
import { PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
|
||||||
import { maxRangeKm } from '@/lib/sim/movement';
|
import { maxRangeKm } from '@/lib/sim/movement';
|
||||||
import { formatCash, formatDeadlineRelative } from '@/lib/format';
|
import { formatCargo, formatCash, formatDeadlineRelative } from '@/lib/format';
|
||||||
import { useGameAction } from '@/lib/useGameAction';
|
import { useGameAction } from '@/lib/useGameAction';
|
||||||
import type { HoverTarget } from './hoverTypes';
|
import type { HoverTarget } from './hoverTypes';
|
||||||
|
|
||||||
@@ -225,7 +225,8 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
|||||||
</div>
|
</div>
|
||||||
<div className="contract-sub">
|
<div className="contract-sub">
|
||||||
<span data-testid={`open-package-reward-${pkg.id}`}>{formatCash(pkg.reward)}</span>
|
<span data-testid={`open-package-reward-${pkg.id}`}>{formatCash(pkg.reward)}</span>
|
||||||
<span>{pkg.cargoType} × {pkg.cargoCount}</span>
|
{/* ISS-066 (v0.35.0): cargo shows as type + tonnage ("Mail 10.0t"), not "type × count". */}
|
||||||
|
<span data-testid={`open-package-cargo-${pkg.id}`}>{formatCargo(pkg.cargoType, pkg.weightKg)}</span>
|
||||||
<span data-testid={`open-package-deadline-${pkg.id}`}>
|
<span data-testid={`open-package-deadline-${pkg.id}`}>
|
||||||
{formatDeadlineRelative(pkg.deadlineSimTime, clockMs)}
|
{formatDeadlineRelative(pkg.deadlineSimTime, clockMs)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
+3
-3
@@ -439,7 +439,7 @@ test('hovering the Relocate section previews the planned route and target airpor
|
|||||||
}).toBe(0);
|
}).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('hovering an on-board block of a flying plane highlights its route', async ({ page }) => {
|
test('hovering the fleet row of a flying plane highlights its route', async ({ page }) => {
|
||||||
test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup
|
test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
@@ -458,8 +458,8 @@ test('hovering an on-board block of a flying plane highlights its route', async
|
|||||||
expect(fly.status()).toBe(200);
|
expect(fly.status()).toBe(200);
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
|
|
||||||
// The on-board block shows the plane in flight once a snapshot with status 'flying' lands.
|
// The fleet row shows the plane in flight once a snapshot with status 'flying' lands (ISS-054).
|
||||||
const block = page.getByTestId('onboard-plane-starter');
|
const block = page.getByTestId('plane-plane-starter');
|
||||||
await expect(block).toContainText(/in flight/i, { timeout: 15_000 });
|
await expect(block).toContainText(/in flight/i, { timeout: 15_000 });
|
||||||
|
|
||||||
// Freeze mid-flight so the ~10 s leg can't land under us while we assert (same pattern as
|
// Freeze mid-flight so the ~10 s leg can't land under us while we assert (same pattern as
|
||||||
|
|||||||
+3
-4
@@ -70,10 +70,9 @@ test('left pane renders wallet, reputation and the starter fleet at Frankfurt',
|
|||||||
// Sim clock renders (UTC wall-clock of the game time).
|
// Sim clock renders (UTC wall-clock of the game time).
|
||||||
await expect(page.getByTestId('sim-clock')).toContainText(/UTC/);
|
await expect(page.getByTestId('sim-clock')).toContainText(/UTC/);
|
||||||
|
|
||||||
// ISS-021 (v0.20.0): the On board section moved to the left pane (between Fleet and Time) —
|
// ISS-054 (v0.33.0): On board merged into the Fleet list — its heading is gone from both panes,
|
||||||
// its heading renders here…
|
// per-plane cargo rows now render inside the fleet rows.
|
||||||
await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'On board', exact: true })).toBeVisible();
|
await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'On board', exact: true })).toHaveCount(0);
|
||||||
// …and no longer in the right pane.
|
|
||||||
await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'On board', exact: true })).toHaveCount(0);
|
await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'On board', exact: true })).toHaveCount(0);
|
||||||
|
|
||||||
// ISS-035 (v0.23.0): the Refuel-to-full row moved to the left pane next to Relocate — its section
|
// ISS-035 (v0.23.0): the Refuel-to-full row moved to the left pane next to Relocate — its section
|
||||||
|
|||||||
@@ -233,6 +233,10 @@ test('queue display + cancel via UI (ISS-043 part 2)', async ({ page }) => {
|
|||||||
}, { timeout: 15_000, message: 'waiting for the fly click to take effect (status "flying" + dest set)' }).toBe(true);
|
}, { timeout: 15_000, message: 'waiting for the fly click to take effect (status "flying" + dest set)' }).toBe(true);
|
||||||
await postSim(page, { paused: true }); // pause-sim pattern: deterministic window for UI clicks
|
await postSim(page, { paused: true }); // pause-sim pattern: deterministic window for UI clicks
|
||||||
|
|
||||||
|
// ISS-065: re-queuing the CURRENT leg's destination while flying must be rejected with 409.
|
||||||
|
const dupQueue = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: DEST } });
|
||||||
|
expect(dupQueue.status()).toBe(409);
|
||||||
|
|
||||||
// ADD a leg to the queue via the Relocate section (ISS-053): the starter is flying, so relocate-go
|
// ADD a leg to the queue via the Relocate section (ISS-053): the starter is flying, so relocate-go
|
||||||
// posts to /queue and the leg starts when it lands.
|
// posts to /queue and the leg starts when it lands.
|
||||||
await page.getByTestId('relocate-plane').selectOption('plane-starter');
|
await page.getByTestId('relocate-plane').selectOption('plane-starter');
|
||||||
|
|||||||
@@ -158,9 +158,9 @@ function maxRangeKm(burnPerKm: number, emptyWeightKg: number, maxFuel: number):
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
|
// 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<PackageState> {
|
async function createPackage(page: Page, fromIcao: string, toIcao: string, deadlineOffsetMs?: number, weightKg?: number): Promise<PackageState> {
|
||||||
const created = await page.request.post('/api/test/create-package', {
|
const created = await page.request.post('/api/test/create-package', {
|
||||||
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs },
|
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs, weightKg },
|
||||||
});
|
});
|
||||||
expect(created.status()).toBe(201);
|
expect(created.status()).toBe(201);
|
||||||
return (await created.json()) as PackageState;
|
return (await created.json()) as PackageState;
|
||||||
@@ -213,9 +213,12 @@ test('open packages render with names, reward and deadline; Load is enabled at a
|
|||||||
|
|
||||||
// Deterministic target: a hook-created EDDF package — Load is enabled (starter at origin, free slot).
|
// Deterministic target: a hook-created EDDF package — Load is enabled (starter at origin, free slot).
|
||||||
const nearest = await nearestToHub(page);
|
const nearest = await nearestToHub(page);
|
||||||
const pkg = await createPackage(page, HUB, nearest.icao);
|
const pkg = await createPackage(page, HUB, nearest.icao, undefined, 4_000);
|
||||||
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
|
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
|
||||||
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
|
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
|
||||||
|
// ISS-066 (v0.35.0): cargo shows as type + tonnage ("… 4.0t"), not "type × count".
|
||||||
|
// 4 t is within the starter ATR's 4.8 t maxPayloadKg, so Load stays enabled below.
|
||||||
|
await expect(page.getByTestId(`open-package-cargo-${pkg.id}`)).toContainText('4.0t');
|
||||||
await expect(page.getByTestId(`open-package-reward-${pkg.id}`)).toContainText(eur(pkg.reward));
|
await expect(page.getByTestId(`open-package-reward-${pkg.id}`)).toContainText(eur(pkg.reward));
|
||||||
const loadBtn = page.getByTestId(`load-${pkg.id}`);
|
const loadBtn = page.getByTestId(`load-${pkg.id}`);
|
||||||
await expect(loadBtn).toBeEnabled();
|
await expect(loadBtn).toBeEnabled();
|
||||||
@@ -268,8 +271,8 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
|
|||||||
await expect(flyBtn).toBeEnabled();
|
await expect(flyBtn).toBeEnabled();
|
||||||
await flyBtn.click();
|
await flyBtn.click();
|
||||||
|
|
||||||
// The on-board block now shows the plane in flight with its current leg…
|
// The fleet row now shows the plane in flight with its current leg (ISS-054)…
|
||||||
await expect(page.getByTestId('onboard-plane-starter')).toContainText(/in flight/i, { timeout: 10_000 });
|
await expect(page.getByTestId('plane-plane-starter')).toContainText(/in flight/i, { timeout: 10_000 });
|
||||||
const leg = page.getByTestId('plane-leg-plane-starter');
|
const leg = page.getByTestId('plane-leg-plane-starter');
|
||||||
await expect(leg).toBeVisible();
|
await expect(leg).toBeVisible();
|
||||||
await expect(leg).toContainText('→');
|
await expect(leg).toContainText('→');
|
||||||
|
|||||||
@@ -323,9 +323,9 @@ test('two grounded planes: Load is one-click with a preselected plane; the picke
|
|||||||
}, { timeout: 15_000, message: 'waiting for the second package to load onto a plane' })
|
}, { timeout: 15_000, message: 'waiting for the second package to load onto a plane' })
|
||||||
.toBe(secondId);
|
.toBe(secondId);
|
||||||
|
|
||||||
// Both planes carry cargo simultaneously — both on-board blocks render.
|
// Both planes carry cargo simultaneously — both fleet rows render their cargo (ISS-054).
|
||||||
await expect(page.getByTestId(`onboard-${STARTER}`), 'the starter block renders').toBeVisible({ timeout: 15_000 });
|
await expect(page.getByTestId(`plane-${STARTER}`), 'the starter row renders').toBeVisible({ timeout: 15_000 });
|
||||||
await expect(page.getByTestId(`onboard-${secondId}`), 'the second plane block renders').toBeVisible({ timeout: 15_000 });
|
await expect(page.getByTestId(`plane-${secondId}`), 'the second plane row renders').toBeVisible({ timeout: 15_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('two planes fly in parallel; the last landing slows to 1× and the next dispatch resumes', async ({ page }) => {
|
test('two planes fly in parallel; the last landing slows to 1× and the next dispatch resumes', async ({ page }) => {
|
||||||
|
|||||||
+10
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { formatCash, formatDeadlineRelative, formatDuration, formatFuel, formatSimClock } from './format';
|
import { formatCargo, formatCash, formatDeadlineRelative, formatDuration, formatFuel, formatSimClock } from './format';
|
||||||
|
|
||||||
describe('formatCash', () => {
|
describe('formatCash', () => {
|
||||||
it('formats EUR with thousands separators and two decimals', () => {
|
it('formats EUR with thousands separators and two decimals', () => {
|
||||||
@@ -21,6 +21,15 @@ describe('formatFuel', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('formatCargo', () => {
|
||||||
|
it('shows cargo type + tonnage with one decimal, like the loadout display', () => {
|
||||||
|
expect(formatCargo('Mail', 10_000)).toBe('Mail 10.0t');
|
||||||
|
// (250 / 1000).toFixed(1) === '0.3' — half rounds up at this value.
|
||||||
|
expect(formatCargo('Electronics', 250)).toBe('Electronics 0.3t');
|
||||||
|
expect(formatCargo('Mail', 40)).toBe('Mail 0.0t');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('formatSimClock', () => {
|
describe('formatSimClock', () => {
|
||||||
it('formats epoch ms as a UTC wall-clock string with seconds (deterministic across timezones)', () => {
|
it('formats epoch ms as a UTC wall-clock string with seconds (deterministic across timezones)', () => {
|
||||||
expect(formatSimClock(Date.UTC(2026, 8, 17, 14, 5))).toBe('2026-09-17 14:05:00 UTC');
|
expect(formatSimClock(Date.UTC(2026, 8, 17, 14, 5))).toBe('2026-09-17 14:05:00 UTC');
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ export function formatFuel(fuel: number, maxFuel: number): string {
|
|||||||
return `${Math.round(fuel)} / ${maxFuel} L`;
|
return `${Math.round(fuel)} / ${maxFuel} L`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Job-list cargo label: "Mail 10.0t" — type + tonnage, one decimal like the loadout display.
|
||||||
|
export function formatCargo(cargoType: string, weightKg: number): string {
|
||||||
|
return `${cargoType} ${(weightKg / 1000).toFixed(1)}t`;
|
||||||
|
}
|
||||||
|
|
||||||
// The sim clock is epoch ms (seeded from real time) — shown as a UTC wall-clock with
|
// The sim clock is epoch ms (seeded from real time) — shown as a UTC wall-clock with
|
||||||
// seconds so the display is deterministic regardless of the viewer's timezone.
|
// seconds so the display is deterministic regardless of the viewer's timezone.
|
||||||
export function formatSimClock(clockMs: number): string {
|
export function formatSimClock(clockMs: number): string {
|
||||||
|
|||||||
@@ -261,6 +261,37 @@ describe('queueLeg (v0.25.0, ISS-043)', () => {
|
|||||||
const inst = await freshDb('qleg-unknownicao.db');
|
const inst = await freshDb('qleg-unknownicao.db');
|
||||||
await expectActionError(queueLeg(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/);
|
await expectActionError(queueLeg(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects a destination already in the queue with 409 (ISS-065)', async () => {
|
||||||
|
const inst = await freshDb('qleg-dup.db');
|
||||||
|
await queueLeg(inst, 'plane-starter', 'LEMD');
|
||||||
|
|
||||||
|
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD'), 409, /already in the flight queue/);
|
||||||
|
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(JSON.parse(plane.queue)).toEqual(['LEMD']); // rejected leg not appended
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects re-queuing the active leg destination of a flying plane with 409 (ISS-065)', async () => {
|
||||||
|
const inst = await freshDb('qleg-dupflying.db');
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD'), 409, /already flying to LEMD/);
|
||||||
|
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(JSON.parse(plane.queue)).toEqual([]); // rejected leg not appended
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still appends a different known airport with success (ISS-065 sanity)', async () => {
|
||||||
|
const inst = await freshDb('qleg-dupother.db');
|
||||||
|
await queueLeg(inst, 'plane-starter', 'LEMD');
|
||||||
|
await expect(queueLeg(inst, 'plane-starter', 'EHAM')).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('cancelQueuedLeg (v0.25.0, ISS-043)', () => {
|
describe('cancelQueuedLeg (v0.25.0, ISS-043)', () => {
|
||||||
|
|||||||
@@ -153,7 +153,16 @@ export async function queueLeg(inst: GameDb, planeId: string, destIcao: string):
|
|||||||
throw new GameActionError(400, `unknown airport ${destIcao}`);
|
throw new GameActionError(400, `unknown airport ${destIcao}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ISS-065: the same route can't be queued twice — re-queuing the active leg's destination or a
|
||||||
|
// leg already in the queue would only produce pointless repeated hops on landing.
|
||||||
|
if (plane.status === 'flying' && plane.destIcao === destIcao) {
|
||||||
|
throw new GameActionError(409, `already flying to ${destIcao}`);
|
||||||
|
}
|
||||||
|
|
||||||
const queue = parseIcaoQueue(plane.queue);
|
const queue = parseIcaoQueue(plane.queue);
|
||||||
|
if (queue.includes(destIcao)) {
|
||||||
|
throw new GameActionError(409, `${destIcao} is already in the flight queue`);
|
||||||
|
}
|
||||||
queue.push(destIcao);
|
queue.push(destIcao);
|
||||||
await inst.db.update(schema.planes).set({ queue: JSON.stringify(queue) }).where(eq(schema.planes.id, planeId));
|
await inst.db.update(schema.planes).set({ queue: JSON.stringify(queue) }).where(eq(schema.planes.id, planeId));
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
|
|||||||
+1
-1
@@ -202,7 +202,7 @@ describe('OpenAPI spec', () => {
|
|||||||
expect(errorCodes('/api/packages/{id}/load')).toEqual(['400', '404', '409']);
|
expect(errorCodes('/api/packages/{id}/load')).toEqual(['400', '404', '409']);
|
||||||
expect(errorCodes('/api/packages/{id}/abandon')).toEqual(['404', '409']);
|
expect(errorCodes('/api/packages/{id}/abandon')).toEqual(['404', '409']);
|
||||||
expect(errorCodes('/api/planes/{planeId}/fly')).toEqual(['400', '404', '409']);
|
expect(errorCodes('/api/planes/{planeId}/fly')).toEqual(['400', '404', '409']);
|
||||||
expect(errorCodes('/api/planes/{planeId}/queue')).toEqual(['400', '404']); // v0.25.0 — no 409: queueing works grounded or flying
|
expect(errorCodes('/api/planes/{planeId}/queue')).toEqual(['400', '404', '409']); // v0.34.0 (ISS-065) — 409 on duplicate leg / active-leg re-queue
|
||||||
expect(errorCodes('/api/planes/{planeId}/queue/cancel')).toEqual(['400', '404']); // v0.25.0
|
expect(errorCodes('/api/planes/{planeId}/queue/cancel')).toEqual(['400', '404']); // v0.25.0
|
||||||
expect(errorCodes('/api/market/buy-plane')).toEqual(['400', '409']);
|
expect(errorCodes('/api/market/buy-plane')).toEqual(['400', '409']);
|
||||||
expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '404', '409']);
|
expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '404', '409']);
|
||||||
|
|||||||
@@ -260,6 +260,11 @@ export const openApiSpec = {
|
|||||||
description: 'Unknown plane id',
|
description: 'Unknown plane id',
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
},
|
},
|
||||||
|
409: {
|
||||||
|
description:
|
||||||
|
'The destination is already in the flight queue, or (while flying) it equals the active leg\'s destination (v0.34.0, ISS-065)',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flight-ops",
|
"name": "flight-ops",
|
||||||
"version": "0.32.0",
|
"version": "0.35.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
Reference in New Issue
Block a user