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
+10 -1
View File
@@ -39,7 +39,16 @@ describe('POST /api/contracts/:id/accept', () => {
planes: Array<Record<string, unknown>>;
contracts: Array<{ id: string; status: string }>;
};
expect(Object.keys(body).sort()).toEqual(['account', 'contracts', 'planes', 'simState']);
// v0.10.0: the snapshot also carries the static market reference data.
expect(Object.keys(body).sort()).toEqual([
'account',
'catalog',
'contracts',
'fuelPricePerL',
'planes',
'sellFraction',
'simState',
]);
expect(body.contracts.find((c) => c.id === open.id)?.status).toBe('accepted');
// Persisted, not just in the response.
+34 -1
View File
@@ -2,6 +2,7 @@ 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 { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
import { GET } from './route';
let dir: string;
@@ -26,7 +27,15 @@ describe('GET /api/state', () => {
expect(res.headers.get('content-type')).toContain('application/json');
const body = (await res.json()) as Record<string, unknown>;
expect(Object.keys(body).sort()).toEqual(['account', 'contracts', 'planes', 'simState']);
expect(Object.keys(body).sort()).toEqual([
'account',
'catalog',
'contracts',
'fuelPricePerL',
'planes',
'sellFraction',
'simState',
]);
const account = body.account as { cash: number; reputation: number };
expect(typeof account.cash).toBe('number');
@@ -56,4 +65,28 @@ describe('GET /api/state', () => {
const b = ((await GET()).json()) as Promise<{ account: { cash: number } }>;
expect((await b).account.cash).toBe((await a).account.cash);
});
it('exposes the catalog + economy constants matching lib/game/catalog.ts (v0.10.0)', async () => {
const body = ((await GET()).json()) as Promise<{
catalog: Array<Record<string, unknown>>;
fuelPricePerL: number;
sellFraction: number;
}>;
const snap = await body;
// One entry per PLANE_TYPES model, values copied straight from the catalog.
expect(snap.catalog.map((e) => e.model).sort()).toEqual(Object.keys(PLANE_TYPES).sort());
for (const [model, type] of Object.entries(PLANE_TYPES)) {
const entry = snap.catalog.find((e) => e.model === model)!;
expect(entry.label).toBe(type.name);
expect(entry.price).toBe(type.price);
expect(entry.maxFuel).toBe(type.maxFuel);
expect(entry.burnPerKm).toBe(type.burnPerKm);
expect(entry.speedKmh).toBe(type.speedKmh);
}
// Economy constants — the single source of truth stays lib/game/catalog.ts.
expect(snap.fuelPricePerL).toBe(FUEL_PRICE_PER_L);
expect(snap.sellFraction).toBe(SELL_FRACTION);
});
});
+90
View File
@@ -240,3 +240,93 @@ body {
text-align: right;
font-variant-numeric: tabular-nums;
}
/* v0.10.0 — right Jobs & Market pane (contracts + market) */
.contract-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.contract-row {
background: rgba(30, 41, 59, 0.6);
border-radius: 6px;
padding: 8px;
}
.contract-main {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.contract-route {
font-weight: 600;
color: #f1f5f9;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contract-sub {
display: flex;
justify-content: space-between;
gap: 8px;
margin-top: 4px;
font-size: 11px;
color: #cbd5e1;
font-variant-numeric: tabular-nums;
}
.pane-btn {
flex-shrink: 0;
font-size: 12px;
padding: 3px 10px;
border-radius: 4px;
border: 1px solid rgba(148, 163, 184, 0.4);
background: rgba(30, 41, 59, 0.9);
color: #e2e8f0;
cursor: pointer;
}
.pane-btn:disabled {
opacity: 0.5;
cursor: default;
}
.dispatch-controls select,
.market-row select {
font-size: 12px;
padding: 3px 6px;
border-radius: 4px;
border: 1px solid rgba(148, 163, 184, 0.4);
background: #1e293b;
color: #e2e8f0;
max-width: 100%;
}
.dispatch-controls {
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
}
.market-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
padding: 4px 0;
}
.market-preview {
font-size: 12px;
color: #f8fafc;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
+5 -5
View File
@@ -2,22 +2,22 @@
import dynamic from 'next/dynamic';
import LeftPane from '@/components/LeftPane';
import RightPane from '@/components/RightPane';
import { useGameState } from '@/lib/useGameState';
const Map = dynamic(() => import('@/components/Map'), { ssr: false });
export default function Home() {
// Single polling source for the whole shell (v0.9.0): one /api/state loop, snapshot passed
// down as props so later panes can share it without extra poll loops.
const { state, loading, error, refresh } = useGameState(1000);
// down as props so later panes can share it without extra poll loops. applySnapshot (v0.10.0)
// lets a pane push an action endpoint's full response snapshot in immediately.
const { state, loading, error, refresh, applySnapshot } = useGameState(1000);
return (
<div className="app-shell">
<LeftPane state={state} loading={loading} error={error} onRefresh={refresh} />
<Map />
<aside data-testid="right-pane" className="pane right-pane">
<h2 className="pane-title">Jobs &amp; Market</h2>
</aside>
<RightPane state={state} loading={loading} applySnapshot={applySnapshot} />
</div>
);
}