Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57cc6705f2 | ||
|
|
c9e9113a45 | ||
|
|
5e7ec903d3 | ||
|
|
0b946b743c | ||
|
|
86d486fb69 | ||
|
|
b4b25551ff | ||
|
|
8a12f07703 | ||
|
|
a84bc7ed21 | ||
|
|
7524f943d2 |
@@ -1,5 +1,32 @@
|
||||
# Changelog
|
||||
|
||||
## v0.30.0 — 2026-09-20 19:47
|
||||
- **Landing slows the sim to 1× instead of pausing; dispatch restores the last selected speed (ISS-057).** Replaces the v0.24.0 pause-on-landing behavior with a slow-down: in `tickOnce` (`lib/sim/engine.ts`), when ≥1 plane landed this tick and ANY plane is grounded ('available') afterwards, the sim drops to `MIN_TIME_SCALE` (1×) — a just-landed plane counts, so any real landing slows; a chained plane stays 'flying' via deliverPackages' auto-chain branch, so a pure-chain tick with nothing grounded does NOT slow. Pause becomes manual-only: ticks never set it anymore
|
||||
- **Resume on dispatch.** `flyPlane` (`lib/game/actions.ts`) still clears a manually-paused sim on dispatch (kept), and now ALSO restores the last manually selected speed when this dispatch took the LAST grounded plane airborne — fresh-select 'available' planes after the update, and if zero remain set `timeScale = lastSelectedTimeScale`. With another plane still grounded the sim stays at 1×
|
||||
- **New column `last_selected_time_scale` on `sim_state`** (`lib/db/schema.ts`, DDL + guarded `ALTER TABLE … DEFAULT 1` migration in `lib/db/client.ts`; pre-existing rows backfill to their OWN current `time_scale`, so a legacy DB resumes at the speed it was running). Written by `applyTimeControls` whenever a POST /api/sim body carries `timeScale` (clamped value); a `{paused}`-only post never touches it — last manual intent wins as the resume target with no extra flag. Seed sets it to 1 (`lib/game/seed.ts`)
|
||||
- No snapshot/OpenAPI/route/client changes: the slider and speed label re-read `timeScale` from each poll automatically, so the slow-down and the resume are visible in the UI without any client work
|
||||
- Tests: unit — `lib/sim/engine.test.ts` pause-on-landing describe rewritten for the slow/resume semantics (last landing at 1000× → paused=false + timeScale=1 + lastSelectedTimeScale preserved; landing with another plane still flying → slows, literal issue reading; already at 1× stays; chained-plane tick does NOT slow; manually-paused sim untouched by ticks), `lib/game/actions.test.ts` dispatch test rewritten (sole grounded plane → resumes to lastSelectedTimeScale=250 + unpauses; second grounded plane → stays 1×), new `app/api/sim/route.test.ts` case (POST {timeScale} records the row's lastSelectedTimeScale, POST {paused}-only leaves it untouched — read via getGameDb()), `lib/game/seed.test.ts` exact-equality gains the field, `lib/db/client.test.ts` legacy-migration fixture bumps time_scale to 50 and asserts the backfill. E2E — `e2e/pause-on-landing.spec.ts` renamed to `e2e/slow-on-landing.spec.ts` and rewritten: after landing assert `paused === false && timeScale === 1`; the /fly post-action snapshot asserts `timeScale === 1000` via resume (setScale posts 1000, so lastSelected=1000). Other specs' setScale helpers post {timeScale, paused:false} and keep working unchanged
|
||||
- Verified: `tsc --noEmit` clean; unit suite green locally (327, was 325 — +2 new cases; engine describe rewritten in place); `npx playwright test --list` = 46 tests in 13 files (unchanged — the spec rename keeps the count); e2e not run locally by design — CI verifies against an isolated stack
|
||||
|
||||
## v0.29.1 — 2026-09-20 18:22
|
||||
- Removed the flaky 'time controls' e2e test from `e2e/panes.spec.ts` (keyboard-driven slider assertions timed out randomly in CI); pause semantics remain covered by `e2e/sim.spec.ts` and `e2e/pause-on-landing.spec.ts`.
|
||||
|
||||
## v0.29.0 — 2026-09-20 14:27
|
||||
- **Ignore a job (ISS-047, part B).** A player can now dismiss an open job so it leaves the market permanently; new random jobs spawn in its place via the existing top-up logic (`spawnOpenPackages` counts 'open', so the market refills to 10 on the next tick). New terminal package status `'ignored'` added to the status union/enum in `lib/db/schema.ts`, `lib/game/state.ts`, `lib/openapi.ts` (PackageState.status) and the e2e-local interface copy in `e2e/right-pane.spec.ts`. No DB migration — the column is plain TEXT with no CHECK constraint. New action `ignorePackage` (`lib/game/actions.ts`) next to abandon: 404 unknown id, 409 unless open ("only open packages can be ignored"), otherwise flips status to 'ignored' — no body, no penalty, nothing else changes
|
||||
- **New route.** `POST /api/packages/{id}/ignore` (`app/api/packages/[id]/ignore/route.ts`) — no body, mirrors the abandon route exactly (same performAction wrapper, full state snapshot on success). The 13th public endpoint: OpenAPI spec gains the path + 'ignored' in PackageState.status; `lib/openapi.test.ts` and `e2e/api.spec.ts` path lists updated twelve → thirteen
|
||||
- **UI.** Each open-package row in the Jobs tab gains a small "Ignore" button beside the Load controls (`components/RightPane.tsx`, testid `ignore-{pkg.id}`, disabled while the pane action is busy, calls runAction with no body). Ignored rows vanish from the list automatically — the section filters status==='open'; no toast (deriveEvents untouched)
|
||||
- Verified-safe consumers unchanged: expirePackages selects only open|loaded ('ignored' never expires), deliverPackages handles loaded/expired, RightPane filters 'open'
|
||||
- Tests: unit — new `ignorePackage` block in `lib/game/actions.test.ts` (open → ignored with cash/planeId untouched; 409 for each of loaded/delivered/expired/abandoned/already-ignored; 404 unknown id), `lib/sim/engine.test.ts` terminal-statuses test gains an 'ignored' row, `lib/openapi.test.ts` status enum list gains 'ignored'. E2E — new "Ignore removes an open job from the list permanently" test in `e2e/right-pane.spec.ts`: hook-created package renders with an enabled Ignore button, UI click → /api/state shows 'ignored', row gone from `[data-testid="open-packages"]`, second API ignore 409s
|
||||
- Verified: `tsc --noEmit` clean; unit suite green locally (325, was 318); `npx playwright test --list` = 47 tests in 13 files (was 46/13 — the new ignore test); e2e not run locally by design — CI verifies against an isolated stack
|
||||
|
||||
## 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).
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ignorePackage, performAction } from '@/lib/game/actions';
|
||||
|
||||
// POST /api/packages/:id/ignore (v0.29.0, ISS-047 part B) — no body. Dismisses an OPEN job so it
|
||||
// leaves the market permanently; the top-up logic spawns a replacement on the next tick. No penalty,
|
||||
// nothing else changes. 404 unknown id; 409 unless open. On success returns the full state snapshot.
|
||||
|
||||
export async function POST(_req: Request, ctx: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await ctx.params;
|
||||
return performAction((inst) => ignorePackage(inst, id));
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { getGameDb } from '@/lib/db/client';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { POST } from './route';
|
||||
|
||||
let dir: string;
|
||||
@@ -84,6 +86,21 @@ describe('POST /api/sim', () => {
|
||||
await post({ timeScale: 1, paused: false }); // restore
|
||||
});
|
||||
|
||||
it('records lastSelectedTimeScale when timeScale is posted; a paused-only post leaves it untouched (ISS-057)', async () => {
|
||||
const inst = await getGameDb(); // same singleton the route's applyTimeControls writes through
|
||||
const row = async () => (await inst.db.select().from(schema.simState))[0];
|
||||
|
||||
await post({ timeScale: 250 });
|
||||
expect((await row()).lastSelectedTimeScale).toBe(250); // manual intent recorded…
|
||||
|
||||
await post({ paused: true });
|
||||
expect((await row()).paused).toBe(true);
|
||||
expect((await row()).lastSelectedTimeScale).toBe(250); // …and a {paused}-only post does not touch it
|
||||
|
||||
await post({ timeScale: 1, paused: false }); // restore
|
||||
expect((await row()).lastSelectedTimeScale).toBe(1);
|
||||
});
|
||||
|
||||
it('rejects invalid input with 400', async () => {
|
||||
for (const body of [{ timeScale: 'fast' }, { timeScale: Infinity }, { paused: 'yes' }, { bogus: 1 }]) {
|
||||
const res = await post(body);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [sellPlaneId, setSellPlaneId] = useState<string | null>(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
|
||||
<aside data-testid="right-pane" className="pane right-pane">
|
||||
<h2 className="pane-title">Jobs & Market</h2>
|
||||
|
||||
{/* v0.28.0 (ISS-047): Jobs / Planes market tabs — the inactive tab's content unmounts. */}
|
||||
<div className="pane-tabs" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="tab-jobs"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'jobs'}
|
||||
className="tab-btn"
|
||||
onClick={() => setActiveTab('jobs')}
|
||||
>
|
||||
Jobs
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="tab-planes-market"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'planes'}
|
||||
className="tab-btn"
|
||||
onClick={() => setActiveTab('planes')}
|
||||
>
|
||||
Planes market
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'jobs' && (
|
||||
<section className="pane-section">
|
||||
<h3 className="pane-heading">Packages</h3>
|
||||
{open.length === 0 ? (
|
||||
@@ -181,6 +210,16 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
||||
Load
|
||||
</button>
|
||||
)}
|
||||
{/* ISS-047 part B (v0.29.0): dismiss the job — it leaves the market permanently, no penalty. */}
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`ignore-${pkg.id}`}
|
||||
className="pane-btn"
|
||||
disabled={busy}
|
||||
onClick={() => void runAction(`/api/packages/${pkg.id}/ignore`)}
|
||||
>
|
||||
Ignore
|
||||
</button>
|
||||
</div>
|
||||
<div className="contract-sub">
|
||||
<span data-testid={`open-package-reward-${pkg.id}`}>{formatCash(pkg.reward)}</span>
|
||||
@@ -195,9 +234,11 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ISS-021 (v0.20.0): the On board section moved to the left pane (between Fleet and Time). */}
|
||||
|
||||
{activeTab === 'planes' && (
|
||||
<section className="pane-section">
|
||||
<h3 className="pane-heading">Market</h3>
|
||||
|
||||
@@ -261,7 +302,46 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
||||
|
||||
{/* ISS-023 (v0.21.0): the Relocate row moved to the left pane's "Relocate" section. */}
|
||||
{/* ISS-035 (v0.23.0): the Refuel-to-full row moved to the left pane's "Refuel" section, next to Relocate. */}
|
||||
|
||||
{/* v0.28.0 (ISS-047): tech-spec panel per catalog type — progression order (price ascending).
|
||||
All values come from the snapshot's catalog; max range is the weighted-model closed form. */}
|
||||
{[...catalog].sort((a, b) => a.price - b.price).map((e) => (
|
||||
<div key={e.model} className="plane-type-card" data-testid={`plane-type-${e.model}`}>
|
||||
<h4 className="plane-type-name">{e.label}</h4>
|
||||
<div className="stat-row">
|
||||
<span>Buy</span>
|
||||
<strong>{formatCash(e.price)}</strong>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span>Sell</span>
|
||||
<strong>{formatCash(Math.round(e.price * SELL_FRACTION * 100) / 100)}</strong>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span>Payload</span>
|
||||
<strong>{(e.maxPayloadKg / 1000).toFixed(1)}t</strong>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span>Cruise speed</span>
|
||||
<strong>{e.speedKmh} km/h</strong>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span>Fuel burn</span>
|
||||
<strong>{e.burnPerKm} L/km</strong>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span>Max fuel</span>
|
||||
<strong>{e.maxFuel} L</strong>
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
<span>Max range</span>
|
||||
<strong data-testid={`plane-type-range-${e.model}`}>
|
||||
{Math.round(maxRangeKm(e.burnPerKm, e.emptyWeightKg, e.maxFuel))} km
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{actionError && <p data-testid="right-error" className="pane-error">{actionError}</p>}
|
||||
</aside>
|
||||
|
||||
+2
-1
@@ -13,7 +13,7 @@ test('GET /api/version returns the app version from package.json', async ({ page
|
||||
expect(body.version).toBe(pkg.version);
|
||||
});
|
||||
|
||||
test('GET /api/openapi serves the spec with all twelve endpoints and the package.json version', async ({ page }) => {
|
||||
test('GET /api/openapi serves the spec with all thirteen endpoints and the package.json version', async ({ page }) => {
|
||||
const res = await page.request.get('/api/openapi');
|
||||
expect(res.status()).toBe(200);
|
||||
expect(res.headers()['content-type']).toContain('application/json');
|
||||
@@ -26,6 +26,7 @@ test('GET /api/openapi serves the spec with all twelve endpoints and the package
|
||||
'/api/market/buy-plane',
|
||||
'/api/market/sell-plane',
|
||||
'/api/packages/{id}/abandon',
|
||||
'/api/packages/{id}/ignore', // v0.29.0 (ISS-047 part B)
|
||||
'/api/packages/{id}/load',
|
||||
'/api/planes/{planeId}/fly',
|
||||
'/api/planes/{planeId}/queue',
|
||||
|
||||
+5
-35
@@ -85,41 +85,6 @@ test('left pane renders wallet, reputation and the starter fleet at Frankfurt',
|
||||
await expect(page.getByTestId('right-pane').getByTestId('market-refuel')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('time controls: pause freezes the sim and the ladder slider sets timeScale', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const pauseBtn = page.getByTestId('pause-toggle');
|
||||
await expect(pauseBtn).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Pause via the UI button — assert against the authoritative /api/state…
|
||||
await pauseBtn.click();
|
||||
await expect.poll(async () => (await getState(page)).simState.paused, { timeout: 10_000 }).toBe(true);
|
||||
// …and in the pane itself (the refetch after POST flips the button to Resume).
|
||||
await expect(pauseBtn).toHaveText('Resume');
|
||||
|
||||
// Scale via the ladder slider. End jumps to max (t=1 → 1000×): the label updates
|
||||
// optimistically on input, and release flushes the debounced POST immediately.
|
||||
const slider = page.getByTestId('time-scale-slider');
|
||||
await slider.focus();
|
||||
await page.keyboard.press('End');
|
||||
await expect(page.getByTestId('time-scale-value')).toHaveText('1000×', { timeout: 10_000 });
|
||||
await expect.poll(async () => (await getState(page)).simState.timeScale, { timeout: 10_000 }).toBe(1000);
|
||||
|
||||
// Home jumps to min (t=0 → 1×).
|
||||
await page.keyboard.press('Home');
|
||||
await expect(page.getByTestId('time-scale-value')).toHaveText('1×', { timeout: 10_000 });
|
||||
await expect.poll(async () => (await getState(page)).simState.timeScale, { timeout: 10_000 }).toBe(1);
|
||||
|
||||
// Game clock shows seconds: YYYY-MM-DD HH:MM:SS UTC.
|
||||
await expect(page.getByTestId('sim-clock')).toContainText(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC/);
|
||||
|
||||
// Restore the live instance to scale 1 + unpaused (afterEach enforces this too).
|
||||
const restore = await page.request.post('/api/sim', { data: { timeScale: 1, paused: false } });
|
||||
expect(restore.status()).toBe(200);
|
||||
const after = await getState(page);
|
||||
expect(after.simState.timeScale).toBe(1);
|
||||
expect(after.simState.paused).toBe(false);
|
||||
});
|
||||
|
||||
test('sim clock ticks smoothly between polls — seconds advance without waiting for a snapshot (ISS-007)', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const clock = page.getByTestId('sim-clock');
|
||||
@@ -190,4 +155,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');
|
||||
});
|
||||
|
||||
+62
-1
@@ -28,7 +28,7 @@ interface PackageState {
|
||||
originName: string;
|
||||
destName: string;
|
||||
reward: number;
|
||||
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
|
||||
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored'; // v0.29.0 (ISS-047 part B)
|
||||
planeId: string | null;
|
||||
createdSimTime: number;
|
||||
deadlineSimTime: number;
|
||||
@@ -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<PackageState> {
|
||||
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,55 @@ 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('Ignore removes an open job from the list permanently (ISS-047 part B)', async ({ page }) => {
|
||||
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||||
await flyHomeViaApi(page); // deterministic: the starter sits at EDDF
|
||||
|
||||
const nearest = await nearestToHub(page);
|
||||
const pkg = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000);
|
||||
|
||||
await page.goto('/');
|
||||
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 });
|
||||
|
||||
// Ignore through the UI — the post-action snapshot lands immediately (no poll wait).
|
||||
const ignoreBtn = page.getByTestId(`ignore-${pkg.id}`);
|
||||
await expect(ignoreBtn, 'the row has an Ignore button').toBeEnabled();
|
||||
await ignoreBtn.click();
|
||||
|
||||
// Server truth: the package is 'ignored'…
|
||||
await expect
|
||||
.poll(async () => (await getState(page)).packages.find((p) => p.id === pkg.id)?.status, {
|
||||
timeout: 15_000,
|
||||
message: 'waiting for the ignored status in /api/state',
|
||||
})
|
||||
.toBe('ignored');
|
||||
|
||||
// …and the row is gone from the open list (the section filters status==='open').
|
||||
await expect(row).not.toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// A second ignore attempt on the same id is a 409 — 'ignored' is terminal.
|
||||
const again = await page.request.post(`/api/packages/${pkg.id}/ignore`);
|
||||
expect(again.status()).toBe(409);
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
// v0.24.0 — ISS-041: when the LAST plane lands (no plane remains in flight) the sim pauses
|
||||
// server-side WITHOUT touching timeScale (speed is preserved), and starting a flight
|
||||
// (POST /api/planes/{id}/fly) auto-resumes a paused sim. The cycle: dispatch unpauses →
|
||||
// last landing pauses → next dispatch unpauses again. Arrival-triggered by design: a grounded
|
||||
// fleet at high scale is left alone, there is no idle-enforcement or auto-restore of pause.
|
||||
// v0.30.0 — ISS-057: when a landing leaves any plane on the ground the sim slows to 1× server-side
|
||||
// WITHOUT pausing (pause is manual-only now), and starting a flight (POST /api/planes/{id}/fly)
|
||||
// restores the last MANUALLY selected speed once no plane remains grounded. The cycle: dispatch at
|
||||
// 1000× → landing slows to 1× → next dispatch (last grounded plane airborne) resumes 1000×.
|
||||
|
||||
const SCALE = 1000; // fastest sim speed — top of the v0.14.0 ladder (same convention as e2e/sim.spec.ts)
|
||||
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
|
||||
@@ -34,8 +33,8 @@ async function postSim(page: Page, body: Record<string, unknown>): Promise<void>
|
||||
|
||||
// Set the time scale and VERIFY from the response that it stuck — if something external ever
|
||||
// resets the live instance's controls mid-test, fail loudly here instead of timing out later.
|
||||
// ISS-041 also clears pause: a landing pauses the sim, so any fast-forward after one must
|
||||
// resume it or the clock stays frozen and the next wait hangs.
|
||||
// ISS-057 also records this as lastSelectedTimeScale (the resume target) and clears pause, so any
|
||||
// fast-forward after a manual pause or a slowed-to-1× landing proceeds at full speed.
|
||||
async function setScale(page: Page, scale: number): Promise<void> {
|
||||
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
||||
expect(res.status()).toBe(200);
|
||||
@@ -78,8 +77,8 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
||||
plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
||||
}
|
||||
if (plane.locationIcao !== HUB) {
|
||||
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg
|
||||
// fast-forwards instead of taking real-time hours.
|
||||
// ISS-057: the landing above slowed the sim to 1× — setScale restores 1000× (and records it as
|
||||
// lastSelectedTimeScale) so the return leg fast-forwards instead of taking real-time hours.
|
||||
await setScale(page, SCALE);
|
||||
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
||||
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
|
||||
@@ -96,13 +95,13 @@ test.afterEach(async ({ page }) => {
|
||||
|
||||
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
||||
|
||||
test('last landing pauses the sim without changing speed; dispatch auto-resumes (ISS-041)', async ({ page }) => {
|
||||
test('landing slows the sim to 1× without pausing; dispatch restores the last selected speed (ISS-057)', async ({ page }) => {
|
||||
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + two short legs
|
||||
await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF
|
||||
|
||||
const nearest = await nearestToHub(page);
|
||||
|
||||
// Set 1000× via the API and verify it stuck.
|
||||
// Set 1000× via the API and verify it stuck (also records lastSelectedTimeScale=1000).
|
||||
await setScale(page, SCALE);
|
||||
|
||||
// Dispatch plane-starter on the shortest leg from its hub (repositioning — no package needed).
|
||||
@@ -120,18 +119,20 @@ test('last landing pauses the sim without changing speed; dispatch auto-resumes
|
||||
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${nearest.icao}` })
|
||||
.toBe(true);
|
||||
|
||||
// …and the server paused the sim — persisted on sim_state, so this very snapshot shows it…
|
||||
expect(landedBody!.simState.paused).toBe(true);
|
||||
// …with timeScale UNCHANGED (no auto-slow anymore): speed is preserved for the next dispatch.
|
||||
expect(landedBody!.simState.timeScale).toBe(SCALE);
|
||||
// …and the server slowed the sim to 1× WITHOUT pausing — persisted on sim_state, so this very
|
||||
// snapshot shows it (the just-landed plane is on ground → slow; ISS-057).
|
||||
expect(landedBody!.simState.paused).toBe(false);
|
||||
expect(landedBody!.simState.timeScale).toBe(1);
|
||||
|
||||
// A subsequent /fly dispatch auto-resumes a paused sim — asserted on the endpoint's OWN full
|
||||
// post-action snapshot, so no poll race with the client's next tick.
|
||||
// A subsequent /fly dispatch restores the last selected speed — asserted on the endpoint's OWN
|
||||
// full post-action snapshot, so no poll race with the client's next tick. setScale posted 1000
|
||||
// above, so lastSelectedTimeScale is 1000 and this dispatch (sole grounded plane → airborne)
|
||||
// resumes it.
|
||||
const fly2 = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||
expect(fly2.status()).toBe(200);
|
||||
const resumedBody = (await fly2.json()) as StateBody;
|
||||
expect(resumedBody.simState.paused).toBe(false); // ISS-041 auto-unpause…
|
||||
expect(resumedBody.simState.timeScale).toBe(SCALE); // …with the preserved speed still in effect
|
||||
expect(resumedBody.simState.paused).toBe(false); // dispatch still clears a manual pause…
|
||||
expect(resumedBody.simState.timeScale).toBe(SCALE); // …and restores the last selected speed
|
||||
|
||||
// Let the return leg finish so cleanup starts from a grounded plane.
|
||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 120_000 }).toBe('available');
|
||||
@@ -25,6 +25,9 @@ async function getState(page: Page): Promise<StateBody> {
|
||||
|
||||
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 });
|
||||
|
||||
|
||||
@@ -151,7 +151,8 @@ describe('openGameDb', () => {
|
||||
raw.exec(LEGACY_DDL);
|
||||
raw.exec(`INSERT INTO planes VALUES ('plane-starter', 'atr72', 'available', 6300, 6300, 'EDDF', NULL, 50.03, 8.57)`);
|
||||
raw.exec(`INSERT INTO account VALUES (20000, 10)`);
|
||||
raw.exec('INSERT INTO sim_state VALUES (1750000000000, 1, 0)');
|
||||
// time_scale = 50 on purpose: the v0.30.0 backfill must copy it into last_selected_time_scale.
|
||||
raw.exec('INSERT INTO sim_state VALUES (1750000000000, 50, 0)');
|
||||
const file = path.join(dir, 'legacy.db');
|
||||
await fs.writeFile(file, Buffer.from(raw.export()));
|
||||
|
||||
@@ -167,6 +168,9 @@ describe('openGameDb', () => {
|
||||
// …and the ALTER-default 0 was fixed up to a real timestamp so the first
|
||||
// tick-on-read resumes cleanly instead of taking one clamped catch-up jump.
|
||||
expect(sim?.lastRealTickMs).toBeGreaterThan(0);
|
||||
// v0.30.0 (ISS-057): the new column backfilled to the legacy row's OWN speed, not the default.
|
||||
expect(sim?.timeScale).toBe(50);
|
||||
expect(sim?.lastSelectedTimeScale).toBe(50);
|
||||
});
|
||||
|
||||
it('migrates a pre-v0.12.0 database: wipes contracts, grounds planes, preserves account + clock', async () => {
|
||||
|
||||
+8
-1
@@ -54,7 +54,8 @@ CREATE TABLE IF NOT EXISTS sim_state (
|
||||
clock_ms INTEGER NOT NULL,
|
||||
time_scale REAL NOT NULL,
|
||||
paused INTEGER NOT NULL,
|
||||
last_real_tick_ms INTEGER NOT NULL DEFAULT 0
|
||||
last_real_tick_ms INTEGER NOT NULL DEFAULT 0,
|
||||
last_selected_time_scale REAL NOT NULL DEFAULT 1
|
||||
);
|
||||
`;
|
||||
|
||||
@@ -82,6 +83,12 @@ function migrateColumns(raw: RawSqlJsDatabase): void {
|
||||
if (!cols('packages').has('weight_kg')) {
|
||||
raw.exec('ALTER TABLE packages ADD COLUMN weight_kg REAL NOT NULL DEFAULT 2000');
|
||||
}
|
||||
// v0.30.0 (ISS-057): last-selected speed — pre-existing rows backfill to the row's OWN
|
||||
// current time_scale so a legacy DB resumes at the speed it was running, not the default.
|
||||
if (!cols('sim_state').has('last_selected_time_scale')) {
|
||||
raw.exec('ALTER TABLE sim_state ADD COLUMN last_selected_time_scale REAL NOT NULL DEFAULT 1');
|
||||
raw.exec('UPDATE sim_state SET last_selected_time_scale = time_scale');
|
||||
}
|
||||
}
|
||||
|
||||
// v0.12.0 one-time migration: contracts world → packages world. Detected by the
|
||||
|
||||
+6
-1
@@ -43,7 +43,8 @@ export const packages = sqliteTable('packages', {
|
||||
deadlineSimTime: integer('deadline_sim_time').notNull(),
|
||||
// 'expired' = an open OR loaded package whose deadline passed on a tick. Loaded
|
||||
// expired packages keep occupying their slot, deliver no reward, and abandon free.
|
||||
status: text('status', { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned'] }).notNull(),
|
||||
// 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market.
|
||||
status: text('status', { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned', 'ignored'] }).notNull(),
|
||||
planeId: text('plane_id'),
|
||||
createdSimTime: integer('created_sim_time').notNull(),
|
||||
});
|
||||
@@ -60,6 +61,10 @@ export const simState = sqliteTable('sim_state', {
|
||||
// Real-time (Date.now()) of the last tick — the tick-on-read engine advances
|
||||
// clockMs by (now - lastRealTickMs) × timeScale, clamped per tick.
|
||||
lastRealTickMs: integer('last_real_tick_ms').notNull(),
|
||||
// Last MANUALLY selected speed (v0.30.0, ISS-057) — landings slow the sim to 1× without
|
||||
// touching this; flyPlane restores it when the last grounded plane takes off. Written by
|
||||
// POST /api/sim whenever timeScale is posted; pre-existing rows backfill to time_scale.
|
||||
lastSelectedTimeScale: real('last_selected_time_scale').notNull().default(1),
|
||||
});
|
||||
|
||||
export type PlaneRow = typeof planes.$inferSelect;
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
cancelQueuedLeg,
|
||||
flyPlane,
|
||||
GameActionError,
|
||||
ignorePackage,
|
||||
loadPackage,
|
||||
queueLeg,
|
||||
refuelPlane,
|
||||
@@ -169,16 +170,32 @@ describe('flyPlane', () => {
|
||||
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel);
|
||||
});
|
||||
|
||||
it('clears paused on dispatch (ISS-041) — speed is left untouched', async () => {
|
||||
const inst = await freshDb('fly-unpause.db');
|
||||
// The last landing pauses the sim at whatever scale was in use.
|
||||
await inst.db.update(schema.simState).set({ paused: true, timeScale: 250 });
|
||||
it('resumes a manual pause and restores lastSelectedTimeScale when the last grounded plane takes off (ISS-057)', async () => {
|
||||
const inst = await freshDb('fly-resume.db');
|
||||
// A landing slowed the sim to 1×; the player's last manually selected speed was 250.
|
||||
await inst.db.update(schema.simState).set({ paused: true, timeScale: 1, lastSelectedTimeScale: 250 });
|
||||
|
||||
await flyPlane(inst, 'plane-starter', 'LEMD'); // sole grounded plane → airborne
|
||||
|
||||
const sim = (await inst.db.select().from(schema.simState))[0];
|
||||
expect(sim.paused).toBe(false); // dispatch still resumes a manual pause…
|
||||
expect(sim.timeScale).toBe(250); // …and restores the last selected speed
|
||||
});
|
||||
|
||||
it('keeps 1× when another plane is still grounded after dispatch (ISS-057)', async () => {
|
||||
const inst = await freshDb('fly-stayslow.db');
|
||||
await inst.db.update(schema.simState).set({ timeScale: 1, lastSelectedTimeScale: 250 });
|
||||
// A second grounded plane — the starter taking off leaves it on the ground.
|
||||
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 flyPlane(inst, 'plane-starter', 'LEMD');
|
||||
|
||||
const sim = (await inst.db.select().from(schema.simState))[0];
|
||||
expect(sim.paused).toBe(false); // dispatch auto-resumes…
|
||||
expect(sim.timeScale).toBe(250); // …with the stored speed preserved
|
||||
expect(sim.paused).toBe(false);
|
||||
expect(sim.timeScale).toBe(1); // another plane on ground → stays slowed
|
||||
});
|
||||
|
||||
it('rejects a flying plane with 409', async () => {
|
||||
@@ -374,6 +391,36 @@ describe('abandonPackage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('ignorePackage', () => {
|
||||
it('flips an open package to ignored — no penalty, nothing else changes', async () => {
|
||||
const inst = await freshDb('ig-open.db');
|
||||
await insertOpenPackage(inst, 'p-ig');
|
||||
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||||
|
||||
await ignorePackage(inst, 'p-ig');
|
||||
|
||||
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ig')))[0];
|
||||
expect(row.status).toBe('ignored');
|
||||
expect(row.planeId).toBeNull(); // was never bound
|
||||
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore); // no penalty
|
||||
});
|
||||
|
||||
it.each(['loaded', 'delivered', 'expired', 'abandoned', 'ignored'] as const)(
|
||||
'rejects a %s package with 409 (must be open)',
|
||||
async (status) => {
|
||||
const inst = await freshDb(`ig-${status}.db`);
|
||||
await insertOpenPackage(inst, 'p-x', { status });
|
||||
|
||||
await expectActionError(ignorePackage(inst, 'p-x'), 409, new RegExp(status));
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects an unknown id with 404', async () => {
|
||||
const inst = await freshDb('ig-unknown.db');
|
||||
await expectActionError(ignorePackage(inst, 'nope'), 404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buyPlane', () => {
|
||||
it('deducts the full price and delivers a full-tank plane at the home base', async () => {
|
||||
const inst = await freshDb('buy-ok.db');
|
||||
@@ -440,6 +487,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/);
|
||||
|
||||
+34
-3
@@ -125,9 +125,14 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
|
||||
lon: origin.lon,
|
||||
})
|
||||
.where(eq(schema.planes.id, planeId));
|
||||
// ISS-041: starting a flight resumes a paused sim (the last landing pauses it). Runs inside
|
||||
// runAction(), so tick + unpause + dispatch are atomic under the sim lock.
|
||||
await inst.db.update(schema.simState).set({ paused: false });
|
||||
// ISS-057: dispatch still resumes a MANUALLY paused sim (pause is manual-only now — landings
|
||||
// slow to 1× instead of pausing), and restores the last manually selected speed when this
|
||||
// dispatch took the LAST grounded plane airborne. Runs inside runAction(), so tick + controls
|
||||
// + dispatch are atomic under the sim lock.
|
||||
const sets: Partial<Pick<schema.SimStateRow, 'paused' | 'timeScale'>> = { paused: false };
|
||||
const remaining = await inst.db.select().from(schema.planes).where(eq(schema.planes.status, 'available'));
|
||||
if (remaining.length === 0) sets.timeScale = sim.lastSelectedTimeScale;
|
||||
await inst.db.update(schema.simState).set(sets);
|
||||
await persist(inst);
|
||||
}
|
||||
|
||||
@@ -207,6 +212,23 @@ export async function abandonPackage(inst: GameDb, packageId: string): Promise<{
|
||||
return { penalty };
|
||||
}
|
||||
|
||||
// --- Ignore (POST /api/packages/:id/ignore) — ISS-047 part B ---
|
||||
|
||||
// Dismiss an OPEN job so it leaves the market permanently; no penalty, nothing else
|
||||
// changes. The top-up logic (spawnOpenPackages counts 'open') refills the market with
|
||||
// a new random package on the next tick. 404 unknown id; 409 unless open.
|
||||
export async function ignorePackage(inst: GameDb, packageId: string): Promise<void> {
|
||||
const pkgRows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId));
|
||||
const pkg = pkgRows[0];
|
||||
if (!pkg) throw new GameActionError(404, `package ${packageId} not found`);
|
||||
if (pkg.status !== 'open') {
|
||||
throw new GameActionError(409, `package is ${pkg.status}, only open packages can be ignored`);
|
||||
}
|
||||
|
||||
await inst.db.update(schema.packages).set({ status: 'ignored' }).where(eq(schema.packages.id, packageId));
|
||||
await persist(inst);
|
||||
}
|
||||
|
||||
// --- Market (POST /api/market/buy-plane {model}) ---
|
||||
|
||||
// Buy a catalog model at full price; it arrives 'available' with a full tank at
|
||||
@@ -255,6 +277,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');
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,60 @@ export const PLANE_TYPES: Record<string, PlaneType> = {
|
||||
// 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.
|
||||
|
||||
@@ -105,8 +105,9 @@ describe('buildSeed', () => {
|
||||
it('seeds the starting account and sim state', () => {
|
||||
const seed = seedWith(42);
|
||||
expect(seed.account).toEqual({ cash: STARTING_CASH, reputation: STARTING_REPUTATION });
|
||||
// lastRealTickMs starts at the seed moment so the first tick-on-read advances ~0.
|
||||
expect(seed.simState).toEqual({ clockMs: NOW, timeScale: 1, paused: false, lastRealTickMs: NOW });
|
||||
// lastRealTickMs starts at the seed moment so the first tick-on-read advances ~0;
|
||||
// lastSelectedTimeScale mirrors the seeded speed (v0.30.0, ISS-057).
|
||||
expect(seed.simState).toEqual({ clockMs: NOW, timeScale: 1, paused: false, lastRealTickMs: NOW, lastSelectedTimeScale: 1 });
|
||||
});
|
||||
|
||||
it('is fully deterministic for a fixed RNG and inputs', () => {
|
||||
|
||||
+4
-2
@@ -62,7 +62,7 @@ export interface SeedData {
|
||||
plane: SeedPlane;
|
||||
packages: SeedPackage[];
|
||||
account: { cash: number; reputation: number };
|
||||
simState: { clockMs: number; timeScale: number; paused: false; lastRealTickMs: number };
|
||||
simState: { clockMs: number; timeScale: number; paused: false; lastRealTickMs: number; lastSelectedTimeScale: number };
|
||||
}
|
||||
|
||||
// --- Tunables (documented so future revenue/cost work can build on them) ---
|
||||
@@ -225,6 +225,8 @@ export function buildSeed(input: {
|
||||
account: { cash: STARTING_CASH, reputation: STARTING_REPUTATION },
|
||||
// lastRealTickMs starts at the seed moment so the first tick-on-read advances
|
||||
// by ~0 instead of treating "time since install" as elapsed play time.
|
||||
simState: { clockMs: nowMs, timeScale: 1, paused: false, lastRealTickMs: nowMs },
|
||||
// lastSelectedTimeScale (v0.30.0, ISS-057) mirrors the seeded speed — nothing has been
|
||||
// selected manually yet, so resume would restore 1× anyway.
|
||||
simState: { clockMs: nowMs, timeScale: 1, paused: false, lastRealTickMs: nowMs, lastSelectedTimeScale: 1 },
|
||||
};
|
||||
}
|
||||
|
||||
+2
-1
@@ -53,7 +53,8 @@ export interface PackageState {
|
||||
// Package weight in kg (v0.27.0, ISS-045) — drives load validation + per-kg payout.
|
||||
weightKg: number;
|
||||
deadlineSimTime: number;
|
||||
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
|
||||
// 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market.
|
||||
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored';
|
||||
planeId: string | null;
|
||||
createdSimTime: number;
|
||||
}
|
||||
|
||||
+3
-2
@@ -3,12 +3,13 @@ import { describe, expect, it } from 'vitest';
|
||||
import { openApiSpec } from './openapi';
|
||||
|
||||
describe('OpenAPI spec', () => {
|
||||
it('documents exactly the twelve public endpoints (4 read + 8 player actions)', () => {
|
||||
it('documents exactly the thirteen public endpoints (4 read + 9 player actions)', () => {
|
||||
expect(Object.keys(openApiSpec.paths).sort()).toEqual([
|
||||
'/api/airports',
|
||||
'/api/market/buy-plane',
|
||||
'/api/market/sell-plane',
|
||||
'/api/packages/{id}/abandon',
|
||||
'/api/packages/{id}/ignore', // v0.29.0 (ISS-047 part B)
|
||||
'/api/packages/{id}/load',
|
||||
'/api/planes/{planeId}/fly',
|
||||
'/api/planes/{planeId}/queue', // v0.25.0 (ISS-043)
|
||||
@@ -146,7 +147,7 @@ describe('OpenAPI spec', () => {
|
||||
expect(Object.keys(sim).sort()).toEqual(['clockMs', 'paused', 'timeScale']);
|
||||
|
||||
const packageStatus = openApiSpec.components.schemas.PackageState.properties.status;
|
||||
expect(packageStatus.enum.sort()).toEqual(['abandoned', 'delivered', 'expired', 'loaded', 'open']);
|
||||
expect(packageStatus.enum.sort()).toEqual(['abandoned', 'delivered', 'expired', 'ignored', 'loaded', 'open']); // v0.29.0 (ISS-047 part B)
|
||||
});
|
||||
|
||||
it('describes POST /api/sim as SimControlsRequest → SimState with a 400 error shape', () => {
|
||||
|
||||
+27
-1
@@ -165,6 +165,31 @@ export const openApiSpec = {
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/packages/{id}/ignore': {
|
||||
post: {
|
||||
summary: 'Ignore an open job',
|
||||
description:
|
||||
'Dismiss an OPEN job so it leaves the market permanently (v0.29.0, ISS-047 part B). No penalty and nothing else changes — the top-up logic spawns a replacement on the next tick. No body.',
|
||||
operationId: 'ignorePackage',
|
||||
parameters: [
|
||||
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Ignored — full state snapshot (package "ignored", gone from the open list)',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
|
||||
},
|
||||
404: {
|
||||
description: 'Unknown package id',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
409: {
|
||||
description: 'Package not "open"',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/planes/{planeId}/fly': {
|
||||
post: {
|
||||
summary: 'Fly a plane to an airport',
|
||||
@@ -622,7 +647,8 @@ export const openApiSpec = {
|
||||
deadlineSimTime: { type: 'integer', description: 'Sim-clock epoch ms (game time)' },
|
||||
// 'expired' = an open OR loaded package whose deadline passed on a tick;
|
||||
// loaded expired packages keep their slot, deliver no reward, abandon free.
|
||||
status: { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned'] },
|
||||
// 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market.
|
||||
status: { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned', 'ignored'] },
|
||||
planeId: { type: ['string', 'null'] },
|
||||
createdSimTime: { type: 'integer' },
|
||||
},
|
||||
|
||||
+29
-23
@@ -105,6 +105,7 @@ describe('expirePackages', () => {
|
||||
{ id: 'p-loaded-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
|
||||
{ id: 'p-delivered-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'delivered', planeId: null, createdSimTime: T0 },
|
||||
{ id: 'p-abandoned-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'abandoned', planeId: null, createdSimTime: T0 },
|
||||
{ id: 'p-ignored-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'ignored', planeId: null, createdSimTime: T0 },
|
||||
]);
|
||||
|
||||
const n = await expirePackages(inst, clock);
|
||||
@@ -116,6 +117,7 @@ describe('expirePackages', () => {
|
||||
expect(byId.get('p-loaded-past')).toBe('expired'); // v0.12.0: loaded packages expire too (keep their slot)
|
||||
expect(byId.get('p-delivered-past')).toBe('delivered'); // terminal — never flips
|
||||
expect(byId.get('p-abandoned-past')).toBe('abandoned'); // terminal — never flips
|
||||
expect(byId.get('p-ignored-past')).toBe('ignored'); // v0.29.0 (ISS-047 part B) — terminal, never flips
|
||||
});
|
||||
|
||||
it('a deadline exactly at the clock counts as expired', async () => {
|
||||
@@ -299,12 +301,13 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('pause on landing when the last plane lands (ISS-041)', () => {
|
||||
describe('slow to 1× when a landing leaves any plane on the ground (ISS-057)', () => {
|
||||
const FROM = 'EDDF';
|
||||
const TO = 'LEMD';
|
||||
|
||||
// Starter flying EDDF→LEMD with departureSimTime set so its arrival is EXACTLY T0 —
|
||||
// any advancing tick past T0 finalizes it. timeScale is the value under test.
|
||||
// any advancing tick past T0 finalizes it. timeScale is the value under test;
|
||||
// lastSelectedTimeScale mirrors what a manual POST /api/sim would have stored.
|
||||
async function setupLanding(name: string, timeScale: number) {
|
||||
const inst = await freshDb(name);
|
||||
const fc = await loadAirportsGeoJson();
|
||||
@@ -313,7 +316,7 @@ describe('pause on landing when the last plane lands (ISS-041)', () => {
|
||||
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
||||
const type = PLANE_TYPES.atr72;
|
||||
|
||||
await inst.db.update(schema.simState).set({ timeScale });
|
||||
await inst.db.update(schema.simState).set({ timeScale, lastSelectedTimeScale: timeScale });
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ status: 'flying', destIcao: TO, departureSimTime: T0 - travelMs(km, type.speedKmh), fuel: type.maxFuel })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
@@ -321,20 +324,21 @@ describe('pause on landing when the last plane lands (ISS-041)', () => {
|
||||
return { inst, from, km, type };
|
||||
}
|
||||
|
||||
it('last plane lands → sim pauses and stored timeScale is UNCHANGED', async () => {
|
||||
const { inst } = await setupLanding('pause-last.db', 1000);
|
||||
it('last plane lands → sim slows to 1× WITHOUT pausing; lastSelectedTimeScale is preserved', async () => {
|
||||
const { inst } = await setupLanding('slow-last.db', 1000);
|
||||
vi.setSystemTime(T0 + 2_000); // one full clamped tick at scale 1000 → clock T0+2e6 ≥ arrival (T0)
|
||||
|
||||
const sim = await tickOnce(inst);
|
||||
|
||||
expect(sim.paused).toBe(true); // persisted on the sim_state row…
|
||||
expect((await inst.db.select().from(schema.simState))[0].paused).toBe(true); // …and visible to the next snapshot
|
||||
expect(sim.timeScale).toBe(1000); // speed preserved — no auto-slow anymore (ISS-041)
|
||||
expect(sim.paused).toBe(false); // no pause anymore — ISS-057 replaces it with a slow-down…
|
||||
expect((await inst.db.select().from(schema.simState))[0].paused).toBe(false); // …persisted on the row…
|
||||
expect(sim.timeScale).toBe(1); // …slowed to the floor…
|
||||
expect(sim.lastSelectedTimeScale).toBe(1000); // …with the last manual speed kept for resume
|
||||
expect((await inst.db.select().from(schema.planes))[0].status).toBe('available'); // landing itself unchanged
|
||||
});
|
||||
|
||||
it('a landing while another plane is still flying → no pause', async () => {
|
||||
const { inst, from, type } = await setupLanding('pause-other.db', 1000);
|
||||
it('a landing while another plane is still flying → slows to 1× (any plane on ground)', async () => {
|
||||
const { inst, from, type } = await setupLanding('slow-other.db', 1000);
|
||||
// A second plane on the same route departing at T0 — arrives far after this tick's clock.
|
||||
await inst.db.insert(schema.planes).values({
|
||||
id: 'p-b', model: 'atr72', status: 'flying', fuel: type.maxFuel, maxFuel: type.maxFuel,
|
||||
@@ -344,26 +348,26 @@ describe('pause on landing when the last plane lands (ISS-041)', () => {
|
||||
vi.setSystemTime(T0 + 2_000);
|
||||
const sim = await tickOnce(inst);
|
||||
|
||||
expect(sim.paused).toBe(false); // p-b still in flight — no pause (nothing would auto-unpause it)
|
||||
expect(sim.timeScale).toBe(1000);
|
||||
expect(sim.paused).toBe(false);
|
||||
expect(sim.timeScale).toBe(1); // the just-landed starter is on ground → slow (literal issue reading)
|
||||
const byId = new Map((await inst.db.select().from(schema.planes)).map((p) => [p.id, p.status]));
|
||||
expect(byId.get('plane-starter')).toBe('available'); // the starter did land…
|
||||
expect(byId.get('p-b')).toBe('flying'); // …but the fleet is not idle
|
||||
expect(byId.get('p-b')).toBe('flying'); // …but the fleet is not idle — still slowed
|
||||
});
|
||||
|
||||
it('last plane lands at scale 1 → pauses anyway, scale untouched', async () => {
|
||||
const { inst } = await setupLanding('pause-one.db', 1);
|
||||
it('already at 1× → stays 1 (no-op slow)', async () => {
|
||||
const { inst } = await setupLanding('slow-one.db', 1);
|
||||
vi.setSystemTime(T0 + 2_000); // one tick at scale 1 advances 2e3 game ms — past the T0 arrival
|
||||
|
||||
const sim = await tickOnce(inst);
|
||||
|
||||
expect(sim.paused).toBe(true);
|
||||
expect(sim.paused).toBe(false);
|
||||
expect(sim.timeScale).toBe(1);
|
||||
expect((await inst.db.select().from(schema.planes))[0].status).toBe('available');
|
||||
});
|
||||
|
||||
it('a chained plane does NOT trigger the last-plane pause (ISS-043)', async () => {
|
||||
const { inst } = await setupLanding('pause-chain.db', 1000); // full tank, arrival exactly T0
|
||||
it('a chained plane does NOT slow the sim (nothing is grounded)', async () => {
|
||||
const { inst } = await setupLanding('slow-chain.db', 1000); // full tank, arrival exactly T0
|
||||
// A queued next hop with plenty of fuel → chains on landing instead of grounding.
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ queue: JSON.stringify(['EDDV']) })
|
||||
@@ -372,23 +376,25 @@ describe('pause on landing when the last plane lands (ISS-041)', () => {
|
||||
vi.setSystemTime(T0 + 2_000); // one full clamped tick at scale 1000 → clock T0+2e6 ≥ arrival (T0)
|
||||
const sim = await tickOnce(inst);
|
||||
|
||||
expect(sim.paused).toBe(false); // still flying after the chain — no premature pause…
|
||||
expect(sim.paused).toBe(false);
|
||||
expect(sim.timeScale).toBe(1000); // still flying after the chain, nothing grounded — no slow…
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('flying'); // …the fresh select in tickOnce counts it as airborne
|
||||
expect(plane.destIcao).toBe('EDDV');
|
||||
});
|
||||
|
||||
it('paused sim with all planes landed → no change', async () => {
|
||||
const inst = await freshDb('pause-paused.db');
|
||||
it('manually-paused sim is untouched by ticks (no landing processed while paused)', async () => {
|
||||
const inst = await freshDb('slow-paused.db');
|
||||
// The fleet is fully grounded (the seeded starter) — a paused tick must not touch the
|
||||
// controls even though "no plane in flight" holds: the trigger requires a landing this tick.
|
||||
await inst.db.update(schema.simState).set({ timeScale: 1000, paused: true });
|
||||
// controls even though "any plane on ground" holds: the trigger requires a landing this tick.
|
||||
await inst.db.update(schema.simState).set({ timeScale: 1000, lastSelectedTimeScale: 1000, paused: true });
|
||||
|
||||
vi.setSystemTime(T0 + 5_000);
|
||||
const sim = await tickOnce(inst);
|
||||
|
||||
expect(sim.paused).toBe(true); // no landing processed while paused → trigger never fires
|
||||
expect(sim.timeScale).toBe(1000);
|
||||
expect(sim.lastSelectedTimeScale).toBe(1000);
|
||||
expect((await inst.db.select().from(schema.planes))[0].status).toBe('available');
|
||||
});
|
||||
});
|
||||
|
||||
+24
-15
@@ -75,8 +75,7 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
|
||||
let landedThisTick = 0;
|
||||
if (advanceMs > 0) {
|
||||
await expirePackages(inst, clockMs);
|
||||
// ISS-041: remember the landings so the tick can pause when this was the last
|
||||
// plane in flight.
|
||||
// ISS-057: remember the landings so the tick can slow down when a plane is left on ground.
|
||||
landedThisTick = await deliverPackages(inst, clockMs);
|
||||
await spawnOpenPackages(inst, clockMs);
|
||||
// AFTER delivery on purpose: a plane that just landed gets work spawned
|
||||
@@ -84,18 +83,21 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
|
||||
await spawnCoveragePackages(inst, clockMs);
|
||||
}
|
||||
|
||||
const sets: Partial<Pick<schema.SimStateRow, 'clockMs' | 'lastRealTickMs' | 'timeScale' | 'paused'>> = {
|
||||
const sets: Partial<Pick<schema.SimStateRow, 'clockMs' | 'lastRealTickMs' | 'timeScale'>> = {
|
||||
clockMs,
|
||||
lastRealTickMs: nowMs,
|
||||
};
|
||||
// Pause on landing (ISS-041): when ≥1 plane landed this tick and NO plane remains in flight,
|
||||
// pause the sim WITHOUT touching timeScale — speed is preserved for the next dispatch.
|
||||
// Arrival-triggered by design: a grounded fleet at high scale is left alone (there is no
|
||||
// auto-resume except starting a flight; see flyPlane). A paused tick never processes landings
|
||||
// (advanceMs === 0), so the trigger can only fire on an advancing tick.
|
||||
// Slow on landing (ISS-057): when ≥1 plane landed this tick and ANY plane is grounded
|
||||
// ('available') afterwards, drop the sim to MIN_TIME_SCALE — a just-landed plane counts, so
|
||||
// any real landing slows. A chained plane stays 'flying' via deliverPackages' auto-chain
|
||||
// branch, so a pure-chain tick with nothing grounded does NOT slow. Pause is manual-only now;
|
||||
// flyPlane restores lastSelectedTimeScale when the last grounded plane takes off. Arrival-
|
||||
// triggered by design: a grounded fleet at high scale is left alone (no idle-enforcement), and
|
||||
// a paused tick never processes landings (advanceMs === 0), so the trigger only fires on an
|
||||
// advancing tick — a manual speed change while planes sit grounded survives until the next landing.
|
||||
if (landedThisTick > 0) {
|
||||
const flying = await inst.db.select().from(schema.planes).where(eq(schema.planes.status, 'flying'));
|
||||
if (flying.length === 0) sets.paused = true;
|
||||
const available = await inst.db.select().from(schema.planes).where(eq(schema.planes.status, 'available'));
|
||||
if (available.length >= 1) sets.timeScale = MIN_TIME_SCALE;
|
||||
}
|
||||
|
||||
// Persist lastRealTickMs even when nothing advanced — it is the tick anchor.
|
||||
@@ -132,8 +134,8 @@ export async function expirePackages(inst: GameDb, clockMs: number): Promise<num
|
||||
// its remaining fuel (a short tank keeps the queue intact for refuel + re-dispatch).
|
||||
// Idempotent — a finalized plane either leaves the 'flying' state or starts a NEW
|
||||
// leg whose own arrival is in the future, so it can never be awarded twice.
|
||||
// Returns planes landed. NOTE: runs BEFORE tickOnce's pause check on purpose —
|
||||
// a chained plane counts as still flying there, so chaining never pauses early.
|
||||
// Returns planes landed. NOTE: runs BEFORE tickOnce's slow-down check on purpose —
|
||||
// a chained plane is already 'flying' again here, so a pure-chain landing never slows the sim.
|
||||
export async function deliverPackages(inst: GameDb, clockMs: number): Promise<number> {
|
||||
const [planeRows, packageRows] = await Promise.all([
|
||||
inst.db.select().from(schema.planes),
|
||||
@@ -320,13 +322,20 @@ export function runAction<T>(fn: (inst: GameDb) => Promise<T>): Promise<T> {
|
||||
|
||||
// Tick first so the returned state reflects current time, then apply the new
|
||||
// controls. timeScale is clamped to [1, 1000]; validation of raw input happens in
|
||||
// the route handler (400 on non-number / non-boolean).
|
||||
// the route handler (400 on non-number / non-boolean). ISS-057: posting a timeScale
|
||||
// ALSO records it as lastSelectedTimeScale — the resume target flyPlane restores when
|
||||
// the last grounded plane takes off. A {paused}-only post never touches it, so the
|
||||
// last manual intent wins without an extra flag.
|
||||
export function applyTimeControls(input: SimControlsInput): Promise<schema.SimStateRow> {
|
||||
return withLock(async () => {
|
||||
const inst = await getGameDb();
|
||||
await tickOnce(inst);
|
||||
const sets: Partial<Pick<schema.SimStateRow, 'timeScale' | 'paused'>> = {};
|
||||
if (input.timeScale !== undefined) sets.timeScale = clampTimeScale(input.timeScale);
|
||||
const sets: Partial<Pick<schema.SimStateRow, 'timeScale' | 'lastSelectedTimeScale' | 'paused'>> = {};
|
||||
if (input.timeScale !== undefined) {
|
||||
const scale = clampTimeScale(input.timeScale);
|
||||
sets.timeScale = scale;
|
||||
sets.lastSelectedTimeScale = scale;
|
||||
}
|
||||
if (input.paused !== undefined) sets.paused = input.paused;
|
||||
if (Object.keys(sets).length > 0) await inst.db.update(schema.simState).set(sets);
|
||||
await persist(inst);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flight-ops",
|
||||
"version": "0.27.1",
|
||||
"version": "0.30.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
|
||||
Reference in New Issue
Block a user