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:
+38
-1
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user