feat: v0.10.0 — right Jobs & Market pane (contracts accept/dispatch, market buy/sell/refuel with live previews); catalog + economy constants exposed in /api/state; shared applySnapshot for instant action feedback

This commit is contained in:
2026-09-17 19:18:42 +00:00
parent 03653fc50e
commit cf811dcf4d
15 changed files with 894 additions and 14 deletions
+38 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { formatCash, formatFuel, formatSimClock } from './format';
import { formatCash, formatDeadlineRelative, formatDuration, formatFuel, formatSimClock } from './format';
describe('formatCash', () => {
it('formats EUR with thousands separators and two decimals', () => {
@@ -28,3 +28,40 @@ describe('formatSimClock', () => {
expect(formatSimClock(Date.UTC(2026, 8, 17, 14, 5, 9))).toBe('2026-09-17 14:05:09 UTC');
});
});
const MIN = 60_000;
const HOUR = 3_600_000;
const DAY = 86_400_000;
describe('formatDuration', () => {
it('renders days + hours, hours + minutes, or bare minutes (rounded to the minute)', () => {
expect(formatDuration(2 * DAY + 3 * HOUR)).toBe('2d 3h');
expect(formatDuration(14 * HOUR + 30 * MIN)).toBe('14h 30m');
expect(formatDuration(45 * MIN)).toBe('45m');
expect(formatDuration(60 * MIN)).toBe('1h 0m');
// Rounding to the nearest minute: 29m29s stays at 29, 29m31s rounds up to 30.
expect(formatDuration(29 * MIN + 29_000)).toBe('29m');
expect(formatDuration(29 * MIN + 31_000)).toBe('30m');
});
it('clamps zero and negative input to "0m"', () => {
expect(formatDuration(0)).toBe('0m');
expect(formatDuration(-5 * MIN)).toBe('0m');
});
});
describe('formatDeadlineRelative', () => {
const NOW = Date.UTC(2026, 8, 17, 12, 0);
it('renders "in <duration>" for future deadlines relative to the sim clock', () => {
expect(formatDeadlineRelative(NOW + 14 * HOUR + 30 * MIN, NOW)).toBe('in 14h 30m');
expect(formatDeadlineRelative(NOW + 2 * DAY + 3 * HOUR, NOW)).toBe('in 2d 3h');
expect(formatDeadlineRelative(NOW + 45 * MIN, NOW)).toBe('in 45m');
});
it('renders "now" for sub-minute deadlines and "overdue" at or past the deadline', () => {
expect(formatDeadlineRelative(NOW + 30_000, NOW)).toBe('now');
expect(formatDeadlineRelative(NOW, NOW)).toBe('overdue');
expect(formatDeadlineRelative(NOW - HOUR, NOW)).toBe('overdue');
});
});
+20
View File
@@ -18,3 +18,23 @@ export function formatSimClock(clockMs: number): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} UTC`;
}
// Compact game-time duration rounded to the minute: "2d 3h" / "14h 30m" / "45m".
export function formatDuration(ms: number): string {
const totalMin = Math.max(0, Math.round(ms / 60_000));
const d = Math.floor(totalMin / (24 * 60));
const h = Math.floor((totalMin % (24 * 60)) / 60);
const m = totalMin % 60;
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
// Contract deadline relative to the sim clock: "in 14h 30m"; a passed deadline reads
// "overdue" and a sub-minute one "now". Pure — unit-testable, no game logic.
export function formatDeadlineRelative(deadlineMs: number, nowMs: number): string {
const diff = deadlineMs - nowMs;
if (diff <= 0) return 'overdue';
if (diff < 60_000) return 'now'; // sub-minute — "in 1m" starts at a full minute
return `in ${formatDuration(diff)}`;
}
+25
View File
@@ -36,3 +36,28 @@ export const FUEL_PRICE_PER_L = 0.8;
// Fraction of the catalog price paid out when selling a plane (v0.8.0).
export const SELL_FRACTION = 0.5;
// Snapshot shape of one catalog entry in GET /api/state (v0.10.0): `model` is the
// id used by buy/dispatch, `label` the display name — clients compute all market
// previews (buy price, sell payout, refuel cost) from these + the economy constants.
export interface CatalogEntry {
model: string;
label: string;
price: number;
maxFuel: number;
burnPerKm: number;
speedKmh: number;
}
// The catalog as a flat array, one entry per plane type — the single source of
// truth for both the sim (PLANE_TYPES) and the client snapshot.
export function catalogEntries(): CatalogEntry[] {
return Object.values(PLANE_TYPES).map((t) => ({
model: t.id,
label: t.name,
price: t.price,
maxFuel: t.maxFuel,
burnPerKm: t.burnPerKm,
speedKmh: t.speedKmh,
}));
}
+9 -1
View File
@@ -2,7 +2,7 @@ import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo';
import { getGameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { PLANE_TYPES } from './catalog';
import { catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog';
import { fuelAt, progressAt, positionAt } from '@/lib/sim/movement';
// Read-only game-state snapshot for client polling (GET /api/state).
@@ -43,6 +43,11 @@ export interface StateSnapshot {
simState: { clockMs: number; timeScale: number; paused: boolean };
planes: PlaneState[];
contracts: ContractState[];
// Market reference data (v0.10.0) — static per build, sent with every snapshot so
// the client can compute buy/sell/refuel previews without a separate endpoint.
catalog: CatalogEntry[];
fuelPricePerL: number;
sellFraction: number;
}
export async function getStateSnapshot(): Promise<StateSnapshot> {
@@ -84,6 +89,9 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
return {
account: { cash: accountRow.cash, reputation: accountRow.reputation },
simState: { clockMs: simRow.clockMs, timeScale: simRow.timeScale, paused: simRow.paused },
catalog: catalogEntries(),
fuelPricePerL: FUEL_PRICE_PER_L,
sellFraction: SELL_FRACTION,
planes: planeRows.map((p) => {
const live = livePlane(p);
return {
+29 -1
View File
@@ -62,11 +62,39 @@ describe('OpenAPI spec', () => {
expect(schema.$ref).toBe('#/components/schemas/StateSnapshot');
const snap = openApiSpec.components.schemas.StateSnapshot.properties;
expect(Object.keys(snap).sort()).toEqual(['account', 'contracts', 'planes', 'simState']);
expect(Object.keys(snap).sort()).toEqual([
'account',
'catalog',
'contracts',
'fuelPricePerL',
'planes',
'sellFraction',
'simState',
]);
expect(openApiSpec.components.schemas.StateSnapshot.required.sort()).toEqual([
'account',
'catalog',
'contracts',
'fuelPricePerL',
'planes',
'sellFraction',
'simState',
]);
expect(snap.account.$ref).toBe('#/components/schemas/AccountState');
expect(snap.simState.$ref).toBe('#/components/schemas/SimState');
expect(snap.planes.items.$ref).toBe('#/components/schemas/PlaneState');
expect(snap.contracts.items.$ref).toBe('#/components/schemas/ContractState');
expect(snap.catalog.items.$ref).toBe('#/components/schemas/CatalogEntry');
expect(snap.fuelPricePerL.type).toBe('number');
expect(snap.sellFraction.type).toBe('number');
const catalog = openApiSpec.components.schemas.CatalogEntry.properties;
expect(Object.keys(catalog).sort()).toEqual(['burnPerKm', 'label', 'maxFuel', 'model', 'price', 'speedKmh']);
for (const key of ['burnPerKm', 'maxFuel', 'price', 'speedKmh'] as const) {
expect(catalog[key].type, `CatalogEntry.${key}`).toBe('number');
}
expect(catalog.model.type).toBe('string');
expect(catalog.label.type).toBe('string');
const plane = openApiSpec.components.schemas.PlaneState.properties;
expect(Object.keys(plane).sort()).toEqual([
+21 -2
View File
@@ -37,7 +37,7 @@ export const openApiSpec = {
get: {
summary: 'Game state snapshot',
description:
'Full current game state for client polling — account, sim clock, planes and contracts. Tick-on-read: each call first advances the sim clock by (real time since last read) × timeScale (clamped per tick), so expiry/finalization/spawn are always applied before the snapshot is returned.',
'Full current game state for client polling — account, sim clock, planes and contracts, plus the static market reference data (catalog + economy constants) so clients can compute buy/sell/refuel previews locally. Tick-on-read: each call first advances the sim clock by (real time since last read) × timeScale (clamped per tick), so expiry/finalization/spawn are always applied before the snapshot is returned.',
operationId: 'getState',
responses: {
200: {
@@ -321,8 +321,27 @@ export const openApiSpec = {
type: 'array',
items: { $ref: '#/components/schemas/ContractState' },
},
catalog: {
type: 'array',
description: 'Aircraft catalog (v0.10.0) — static per build, one entry per plane type.',
items: { $ref: '#/components/schemas/CatalogEntry' },
},
fuelPricePerL: { type: 'number', description: 'Jet-A1 price in €/liter; refuel cost = (maxFuel − fuel) × this.' },
sellFraction: { type: 'number', description: 'Fraction of the catalog price paid out when selling a plane.' },
},
required: ['account', 'simState', 'planes', 'contracts'],
required: ['account', 'simState', 'planes', 'contracts', 'catalog', 'fuelPricePerL', 'sellFraction'],
},
CatalogEntry: {
type: 'object',
properties: {
model: { type: 'string', description: 'Catalog model id (e.g. "atr72") — used by buy/dispatch.' },
label: { type: 'string', description: 'Display name (e.g. "ATR 72-600").' },
price: { type: 'number', description: 'Market price in € (buy cost; sell pays out sellFraction × this).' },
maxFuel: { type: 'number', description: 'Fuel capacity, liters.' },
burnPerKm: { type: 'number', description: 'Cruise fuel burn, liters/km.' },
speedKmh: { type: 'number', description: 'Cruise speed, km/h.' },
},
required: ['model', 'label', 'price', 'maxFuel', 'burnPerKm', 'speedKmh'],
},
AccountState: {
type: 'object',
+14 -1
View File
@@ -10,6 +10,11 @@ export interface GameStateResult {
error: string | null;
// Trigger an immediate refetch (used after POST /api/sim so the UI updates without waiting for the next tick).
refresh: () => void;
// Push a snapshot straight into the shared state (v0.10.0): every action endpoint returns
// the FULL post-action snapshot, so panes can render its result on click instead of waiting
// for the next poll tick. The polling loop keeps running and simply overwrites it with the
// server's truth on its next cycle (which also re-clears any transient pane error).
applySnapshot: (snapshot: StateSnapshot) => void;
}
// Single fetch of the full game-state snapshot. Exported separately from the hook so it can be unit-tested with a mocked fetch.
@@ -45,11 +50,19 @@ export function useGameState(intervalMs = 1000): GameStateResult {
});
}, []);
// v0.10.0 — instant action feedback: panes push the full snapshot returned by an
// action endpoint into the shared state here (see GameStateResult.applySnapshot).
const applySnapshot = useCallback((snapshot: StateSnapshot) => {
setState(snapshot);
setError(null);
setLoading(false);
}, []);
useEffect(() => {
refresh(); // fetch immediately on mount, don't wait for the first interval tick
const id = setInterval(refresh, intervalMs);
return () => clearInterval(id);
}, [intervalMs, refresh]);
return { state, loading, error, refresh };
return { state, loading, error, refresh, applySnapshot };
}