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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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 & Market</h2>
|
||||
</aside>
|
||||
<RightPane state={state} loading={loading} applySnapshot={applySnapshot} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user