fix: v0.12.1 — ISS-001 time-scale slider no longer snaps back to the stale value on speed change (optimistic scale kept until a snapshot confirms it); sim-control commit machinery hardened (synchronous busy ref, in-flight queue flush loop, sent/queued/failed outcomes so queued commits never drop the display)
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.12.1 — 2026-09-18 11:13
|
||||||
|
- **Bugfix (ISS-001): time-scale slider pointer jumped back to the old value, then forward again, on every speed change.** Root cause in `components/LeftPane.tsx`: `commitNow` cleared the optimistic `pendingScale` *before* the server confirmed it — so for one poll cycle the displayed scale fell back to the stale snapshot's value (pointer snapped back), then jumped forward when the confirming snapshot landed. Fix: keep the chosen value on screen until a rendered snapshot actually CONFIRMS it (`serverTimeScale === pendingScale`) and no commit is still in flight; drop it immediately only if the POST genuinely fails, so the display falls back to server truth. The confirmation rule is extracted as a pure exported `pendingScaleConfirmed(pending, server)` for unit testing (same pattern as RightPane's picker rules); the busy guard prevents an old snapshot from coincidentally "confirming" a quick drag back to the previous scale before its own commit fires
|
||||||
|
- **Hardened the sim-control commit machinery while fixing it** (`components/LeftPane.tsx`): the in-flight gate moved from React state to a synchronous ref (closures can no longer see a stale `busy` and re-queue instead of send, which could silently drop a commit), the single queue slot is now flushed by a loop inside the in-flight call (last intent always wins, multiple queued commits all sent), and `applySimControls` reports `'sent' | 'queued' | 'failed'` so an optimistic value is only dropped on genuine failure — never when a commit merely got queued behind another
|
||||||
|
- Tests: unit — new `components/left-pane.test.ts` (no pending → false; stale-scale window stays false; confirms exactly when server carries the pending value). e2e — new `panes.spec.ts` test commits 64× via keyboard and samples the displayed value ~30× across the whole commit→confirm window, asserting it never snaps back to a stale scale (the old bug), then verifies the server confirms
|
||||||
|
- Verified: unit suite 228 → 231 green; `tsc --noEmit` clean; e2e — full suite green on the initial v0.12.1 deploy against the live compose build (incl. the new no-snap-back test); after the commit-machinery restructure, the time-control specs (`panes` + `sim`) verified green in 3 consecutive rounds. Full-suite re-run deferred: the live instance is under active human play and the destructive self-heal specs must not touch it
|
||||||
|
|
||||||
|
|
||||||
## v0.12.0 — 2026-09-18 09:17
|
## v0.12.0 — 2026-09-18 09:17
|
||||||
- **Gameplay pivot (user-driven): contracts → packages** — the job model is rebuilt end-to-end around cargo *packages* and manual flight, replacing the old accept→dispatch flow in one coherent increment. A **package** is a cargo unit origin→dest with a reward € and a game-time deadline; planes carry up to `capacity` packages (ATR 72-600 = **6 slots**, new `capacity` field on catalog entries exposed via `/api/state`). The loop: **Load** an open package onto a grounded plane at its origin → **Fly** the plane to any known airport (the destination of a loaded package, or pure repositioning) → on arrival every in-deadline package bound for that airport is **delivered** (cash += reward + rep per package); packages destined elsewhere stay loaded for a later leg
|
- **Gameplay pivot (user-driven): contracts → packages** — the job model is rebuilt end-to-end around cargo *packages* and manual flight, replacing the old accept→dispatch flow in one coherent increment. A **package** is a cargo unit origin→dest with a reward € and a game-time deadline; planes carry up to `capacity` packages (ATR 72-600 = **6 slots**, new `capacity` field on catalog entries exposed via `/api/state`). The loop: **Load** an open package onto a grounded plane at its origin → **Fly** the plane to any known airport (the destination of a loaded package, or pure repositioning) → on arrival every in-deadline package bound for that airport is **delivered** (cash += reward + rep per package); packages destined elsewhere stay loaded for a later leg
|
||||||
- **New player actions** (`lib/game/actions.ts`, all under the sim lock via `engine.runAction()` — tick first, then validate+mutate): `loadPackage` (open → loaded; plane must be GROUNDED at the origin with a free slot — expired packages still occupy theirs), `flyPlane` (grounded plane → any dataset ICAO; 400 unknown airport, 409 flying / insufficient fuel where burnPerKm × great-circle km > fuel), `abandonPackage` (loaded or already-expired package while its plane is GROUNDED — 409 if in flight). Removed: `acceptContract`, `dispatchPlane`
|
- **New player actions** (`lib/game/actions.ts`, all under the sim lock via `engine.runAction()` — tick first, then validate+mutate): `loadPackage` (open → loaded; plane must be GROUNDED at the origin with a free slot — expired packages still occupy theirs), `flyPlane` (grounded plane → any dataset ICAO; 400 unknown airport, 409 flying / insufficient fuel where burnPerKm × great-circle km > fuel), `abandonPackage` (loaded or already-expired package while its plane is GROUNDED — 409 if in flight). Removed: `acceptContract`, `dispatchPlane`
|
||||||
|
|||||||
+50
-15
@@ -34,44 +34,65 @@ function loadoutFor(state: StateSnapshot, p: PlaneState): string {
|
|||||||
return `${used}/${capacity} pkgs`;
|
return `${used}/${capacity} pkgs`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ISS-001 — an optimistic slider value is cleared exactly when a rendered snapshot CONFIRMS it
|
||||||
|
// (server scale === pending), never earlier: clearing at commit time would snap the pointer back
|
||||||
|
// to the stale scale for one poll cycle, then jump forward again on the confirming snapshot.
|
||||||
|
export function pendingScaleConfirmed(pendingScale: number | null, serverTimeScale: number): boolean {
|
||||||
|
return pendingScale !== null && serverTimeScale === pendingScale;
|
||||||
|
}
|
||||||
|
|
||||||
export default function LeftPane({ state, loading, error, onRefresh }: LeftPaneProps) {
|
export default function LeftPane({ state, loading, error, onRefresh }: LeftPaneProps) {
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
// POST /api/sim, then refetch the full snapshot immediately so the pane reflects the new
|
// POST /api/sim, then refetch the full snapshot immediately so the pane reflects the new
|
||||||
// controls without waiting for the next poll tick. On failure keep the stale UI — the
|
// controls without waiting for the next poll tick. On failure keep the stale UI — the
|
||||||
// running poll retries on its own and surfaces the error state. A commit that lands while
|
// running poll retries on its own and surfaces the error state. A commit that lands while
|
||||||
// another POST is in flight is held in a single slot and flushed right after, so the last
|
// another POST is in flight is held in a single slot (last intent wins) and flushed by the
|
||||||
// user intent always reaches the server (the server's [1,64] clamp stays as backstop).
|
// in-flight call, so every user intent reaches the server (the [1,64] clamp stays as backstop).
|
||||||
|
// The gate is a REF, not React state: closures never see a stale "busy" value, which used to
|
||||||
|
// let a flush re-queue instead of send and silently drop a commit.
|
||||||
const queuedRef = useRef<{ timeScale?: number; paused?: boolean } | null>(null);
|
const queuedRef = useRef<{ timeScale?: number; paused?: boolean } | null>(null);
|
||||||
|
const busyRef = useRef(false);
|
||||||
|
|
||||||
|
type SimControlsOutcome = 'sent' | 'queued' | 'failed';
|
||||||
|
|
||||||
|
// 'sent': this body reached the server (a confirming snapshot follows via onRefresh).
|
||||||
|
// 'queued': an in-flight call holds it — its flush will send it.
|
||||||
|
// 'failed': the POST for this body never succeeded; callers drop optimistic values only then.
|
||||||
const applySimControls = useCallback(
|
const applySimControls = useCallback(
|
||||||
async (body: { timeScale?: number; paused?: boolean }) => {
|
async (body: { timeScale?: number; paused?: boolean }): Promise<SimControlsOutcome> => {
|
||||||
if (busy) {
|
if (busyRef.current) {
|
||||||
queuedRef.current = body;
|
queuedRef.current = body; // last intent wins — the in-flight call's flush sends it
|
||||||
return;
|
return 'queued';
|
||||||
}
|
}
|
||||||
|
busyRef.current = true;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
let sentOwn = false;
|
||||||
try {
|
try {
|
||||||
|
let current: { timeScale?: number; paused?: boolean } | null = body;
|
||||||
|
while (current !== null) {
|
||||||
const res = await fetch('/api/sim', {
|
const res = await fetch('/api/sim', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(current),
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`POST /api/sim → HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`POST /api/sim → HTTP ${res.status}`);
|
||||||
onRefresh();
|
onRefresh();
|
||||||
|
if (current === body) sentOwn = true; // object identity — queued bodies are fresh literals
|
||||||
|
current = queuedRef.current; // pick up anything that arrived while we were in flight
|
||||||
|
queuedRef.current = null;
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('failed to apply sim controls', err);
|
console.error('failed to apply sim controls', err);
|
||||||
|
queuedRef.current = null; // don't replay a stale intent after a failed round-trip
|
||||||
} finally {
|
} finally {
|
||||||
|
busyRef.current = false;
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
const queued = queuedRef.current;
|
|
||||||
if (queued !== null) {
|
|
||||||
queuedRef.current = null;
|
|
||||||
void applySimControls(queued);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return sentOwn ? 'sent' : 'failed';
|
||||||
},
|
},
|
||||||
[busy, onRefresh],
|
[onRefresh],
|
||||||
);
|
);
|
||||||
|
|
||||||
const paused = state?.simState.paused ?? false;
|
const paused = state?.simState.paused ?? false;
|
||||||
@@ -97,10 +118,24 @@ export default function LeftPane({ state, loading, error, onRefresh }: LeftPaneP
|
|||||||
}
|
}
|
||||||
const s = pendingRef.current;
|
const s = pendingRef.current;
|
||||||
if (s === null) return;
|
if (s === null) return;
|
||||||
setPending(null);
|
// ISS-001: keep the chosen value on screen until a snapshot CONFIRMS it — clearing at
|
||||||
void applySimControls({ timeScale: s });
|
// commit time would snap the slider back to the stale scale for one poll cycle, then jump
|
||||||
|
// forward again when the confirming snapshot lands. 'queued' also keeps it (the in-flight
|
||||||
|
// call flushes it); only a genuine failure drops it so the display falls back to truth.
|
||||||
|
void applySimControls({ timeScale: s }).then((outcome) => {
|
||||||
|
if (outcome === 'failed') setPending(null);
|
||||||
|
});
|
||||||
}, [applySimControls, setPending]);
|
}, [applySimControls, setPending]);
|
||||||
|
|
||||||
|
// Confirmation: once a rendered snapshot carries the pending scale AND no commit is in
|
||||||
|
// flight, the optimistic value has been superseded by server truth — clear it (display is
|
||||||
|
// unchanged either way). The busy guard matters: while a commit POST is still out, an OLD
|
||||||
|
// snapshot that happens to match the pending value is coincidence, not confirmation (e.g. a
|
||||||
|
// quick drag back to the previous scale must not be swallowed before its own commit fires).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!busy && state && pendingScaleConfirmed(pendingScale, state.simState.timeScale)) setPending(null);
|
||||||
|
}, [state, pendingScale, busy, setPending]);
|
||||||
|
|
||||||
const onSliderInput = useCallback(
|
const onSliderInput = useCallback(
|
||||||
(e: ChangeEvent<HTMLInputElement>) => {
|
(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
setPending(sliderToScale(Number(e.target.value)));
|
setPending(sliderToScale(Number(e.target.value)));
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { pendingScaleConfirmed } from './LeftPane';
|
||||||
|
|
||||||
|
// ISS-001 — the optimistic slider value is cleared exactly when a rendered snapshot confirms
|
||||||
|
// it (server scale === pending), never earlier. Clearing at commit time would snap the pointer
|
||||||
|
// back to the stale scale for one poll cycle, then jump forward again on the confirming snapshot.
|
||||||
|
|
||||||
|
describe('pendingScaleConfirmed', () => {
|
||||||
|
it('is false while there is no pending value', () => {
|
||||||
|
expect(pendingScaleConfirmed(null, 1)).toBe(false);
|
||||||
|
expect(pendingScaleConfirmed(null, 64)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays false while the server still reports the stale scale (the ISS-001 window)', () => {
|
||||||
|
// User committed 64×; the last rendered snapshot is still at 1× → must NOT clear yet.
|
||||||
|
expect(pendingScaleConfirmed(64, 1)).toBe(false);
|
||||||
|
expect(pendingScaleConfirmed(32, 8)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('becomes true exactly when a snapshot carries the pending scale', () => {
|
||||||
|
expect(pendingScaleConfirmed(64, 64)).toBe(true);
|
||||||
|
expect(pendingScaleConfirmed(1, 1)).toBe(true); // committing the current value confirms immediately
|
||||||
|
});
|
||||||
|
});
|
||||||
+43
-2
@@ -44,11 +44,21 @@ test('left pane renders wallet, reputation and the starter fleet at Frankfurt',
|
|||||||
await expect(page.getByTestId('reputation')).toBeVisible();
|
await expect(page.getByTestId('reputation')).toBeVisible();
|
||||||
expect(Number((await page.getByTestId('reputation').textContent()) ?? 'NaN')).toBeGreaterThanOrEqual(0);
|
expect(Number((await page.getByTestId('reputation').textContent()) ?? 'NaN')).toBeGreaterThanOrEqual(0);
|
||||||
|
|
||||||
// Fleet: the starter ATR 72-600 at Frankfurt (EDDF).
|
// Fleet: the starter ATR 72-600 — location asserted against /api/state truth, because a
|
||||||
|
// human player (or an earlier spec) may have moved it anywhere by now.
|
||||||
const row = page.getByTestId('fleet-list').locator('.plane-row').first();
|
const row = page.getByTestId('fleet-list').locator('.plane-row').first();
|
||||||
await expect(row, 'starter plane row appears once the first snapshot lands').toBeVisible({ timeout: 15_000 });
|
await expect(row, 'starter plane row appears once the first snapshot lands').toBeVisible({ timeout: 15_000 });
|
||||||
await expect(row).toContainText('ATR 72-600');
|
await expect(row).toContainText('ATR 72-600');
|
||||||
await expect(row).toContainText('Frankfurt');
|
const truth = (await getState(page)).planes.find((p) => p.id === 'plane-starter') as unknown as {
|
||||||
|
status: string;
|
||||||
|
locationName: string;
|
||||||
|
destName: string | null;
|
||||||
|
};
|
||||||
|
if (truth.status === 'flying' && truth.destName) {
|
||||||
|
await expect(row).toContainText(`${truth.locationName} → ${truth.destName}`); // in-flight leg
|
||||||
|
} else {
|
||||||
|
await expect(row).toContainText(truth.locationName);
|
||||||
|
}
|
||||||
|
|
||||||
// v0.12.0 loadout: "n/capacity pkgs" (capacity from the snapshot catalog — ATR 72-600 = 6).
|
// v0.12.0 loadout: "n/capacity pkgs" (capacity from the snapshot catalog — ATR 72-600 = 6).
|
||||||
const loadout = page.getByTestId('plane-loadout-plane-starter');
|
const loadout = page.getByTestId('plane-loadout-plane-starter');
|
||||||
@@ -94,6 +104,37 @@ test('time controls: pause freezes the sim and the power-of-two slider sets time
|
|||||||
expect(after.simState.paused).toBe(false);
|
expect(after.simState.paused).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('slider commit keeps the chosen scale on screen until confirmed — no snap-back (ISS-001)', async ({ page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
await page.goto('/');
|
||||||
|
const slider = page.getByTestId('time-scale-slider');
|
||||||
|
await expect(slider).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
// Commit to max via keyboard (input + change → immediate commitNow POST)…
|
||||||
|
await slider.focus();
|
||||||
|
await page.keyboard.press('End');
|
||||||
|
|
||||||
|
// …and sample the displayed value rapidly across the whole commit→confirm window. With the
|
||||||
|
// ISS-001 fix the optimistic value stays until a snapshot confirms it, so EVERY sample must
|
||||||
|
// be '64×' — never snapping back to the previous scale for one poll cycle (the old bug).
|
||||||
|
const seen: string[] = [];
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
seen.push(((await page.getByTestId('time-scale-value').textContent()) ?? '').trim());
|
||||||
|
await new Promise((r) => setTimeout(r, 25));
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
seen.every((v) => v === '64×'),
|
||||||
|
`displayed scale must stay 64× during the commit window (saw: ${[...new Set(seen)].join(', ')})`,
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
// …and the server confirms it.
|
||||||
|
await expect.poll(async () => (await getState(page)).simState.timeScale, { timeout: 10_000 }).toBe(64);
|
||||||
|
|
||||||
|
// Restore to min for later tests (afterEach restores scale anyway).
|
||||||
|
await page.keyboard.press('Home');
|
||||||
|
await expect.poll(async () => (await getState(page)).simState.timeScale, { timeout: 10_000 }).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
test('right pane placeholder exists with the Jobs & Market heading', async ({ page }) => {
|
test('right pane placeholder exists with the Jobs & Market heading', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
const right = page.getByTestId('right-pane');
|
const right = page.getByTestId('right-pane');
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flight-ops",
|
"name": "flight-ops",
|
||||||
"version": "0.12.0",
|
"version": "0.12.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
Reference in New Issue
Block a user