Merge pull request 'v1.6.0: leaderboard pane from the top-bar additional-panels button (ISS-115)' (#160) from feat/multiplayer-leaderboard into master
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.6.0 — 2026-09-26 19:35
|
||||||
|
- **Multiplayer step 11/12 (ISS-115): leaderboard panel.** Small UI listing every player in the world with cash, reputation and plane count, rendered from snapshot data — no new endpoint. The known gap from step 8 is closed additively: `getStateSnapshot(playerId)` (`lib/game/state.ts`) now projects a `players` array (`PlayerSummary[]`: `{ id, username, cash, reputation, planeCount }`, planeCount = planes owned) into the ONE shared snapshot projection, so GET /api/state, action responses and every SSE frame all carry it in sync automatically. No extra query — the players rows were already loaded for the v1.4.0 owner joins; plane counts are a single in-memory pass over the already-loaded plane rows.
|
||||||
|
- **UI.** New "Leaderboard" button on the right side of the map top bar (next to Admin) opens the panel as a modal, reusing the existing `Modal` + `.pane-btn` conventions — per the 2026-09-23 placement decision it is NOT embedded in the left pane. New `components/Leaderboard.tsx`: one row per player (username, cash via `formatCash`, reputation, plane count), sorted by reputation descending with cash then username as deterministic tie-breaks so rows don't shuffle between snapshots on equal rep. Small `.leaderboard` table styles mirror `.admin-rows`.
|
||||||
|
- **OpenAPI.** New `PlayerSummary` schema; `StateSnapshot` gains required `players: PlayerSummary[]` — documented in `lib/openapi.ts`, path count unchanged at eighteen.
|
||||||
|
- **Tests.** Unit: new `getStateSnapshot players` suite in `lib/game/state.test.ts` against a throwaway seeded DB — single-player regression guard (exactly one row for the seeded local account with its starter plane) and a two-player case asserting cash/reputation/planeCount per owner (local keeps 1, player B counts both of its planes). The `/api/state` route test's exact key-list assertion gains `players`; all four typed snapshot fixtures (`state.test.ts`, `mapData.test.ts`, `deriveEvents.test.ts`, `right-pane.test.ts`) gain the new required field. No e2e spec changes: existing specs assert per-key `toHaveProperty` on planes/packages only, and CI's single minted player sees a one-row leaderboard — behavior unchanged; the button/modal wiring is covered by tsc + the modal pattern already exercised in admin.spec.ts.
|
||||||
|
|
||||||
## v1.5.0 — 2026-09-26 19:06
|
## v1.5.0 — 2026-09-26 19:06
|
||||||
- **Multiplayer step 7/12 (ISS-111): shared-world spawn quality rule.** The #101 decision point is resolved with the agreed default proposal: spawned pool packages scale to the MAX reputation across ALL player rows, so job rewards track the best-progressed player and nobody is stuck on low-rep jobs. New `getSharedWorldReputation(inst)` in `lib/db/localPlayer.ts` — one SQL `MAX(reputation)` over the players table, 0 when there are no players (the rep bonus floors at ×1 either way). The engine's pool spawner (`spawnPoolPackages`, `lib/sim/engine.ts`) reads it once per spawn batch and passes it through `generatePoolPackage(..., reputation)`, replacing the last singleton `getLocalPlayer` job-quality read; the env-gated `/api/test/create-package` hook — which documents itself as carrying "the same job quality as engine spawns" — follows the same rule so no surface can drift.
|
- **Multiplayer step 7/12 (ISS-111): shared-world spawn quality rule.** The #101 decision point is resolved with the agreed default proposal: spawned pool packages scale to the MAX reputation across ALL player rows, so job rewards track the best-progressed player and nobody is stuck on low-rep jobs. New `getSharedWorldReputation(inst)` in `lib/db/localPlayer.ts` — one SQL `MAX(reputation)` over the players table, 0 when there are no players (the rep bonus floors at ×1 either way). The engine's pool spawner (`spawnPoolPackages`, `lib/sim/engine.ts`) reads it once per spawn batch and passes it through `generatePoolPackage(..., reputation)`, replacing the last singleton `getLocalPlayer` job-quality read; the env-gated `/api/test/create-package` hook — which documents itself as carrying "the same job quality as engine spawns" — follows the same rule so no surface can drift.
|
||||||
- **Sites that stay on 'local' (deliberate).** The seeded local row is still inserted at first-run seeding (`lib/db/client.ts`) and staged by the env-gated `/api/test/*` economy hooks (add-cash, set-cash) — those stage an account, not job quality. Seed-time package generation (`buildSeed`) passes no reputation at all and keeps defaulting to `STARTING_REPUTATION`, so fresh worlds are byte-identical to before.
|
- **Sites that stay on 'local' (deliberate).** The seeded local row is still inserted at first-run seeding (`lib/db/client.ts`) and staged by the env-gated `/api/test/*` economy hooks (add-cash, set-cash) — those stage an account, not job quality. Seed-time package generation (`buildSeed`) passes no reputation at all and keeps defaulting to `STARTING_REPUTATION`, so fresh worlds are byte-identical to before.
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ describe('POST /api/packages/:id/abandon', () => {
|
|||||||
'fuelPricePerL',
|
'fuelPricePerL',
|
||||||
'packages',
|
'packages',
|
||||||
'planes',
|
'planes',
|
||||||
|
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
|
||||||
'sellFraction',
|
'sellFraction',
|
||||||
'simState',
|
'simState',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ describe('POST /api/packages/:id/load', () => {
|
|||||||
'fuelPricePerL',
|
'fuelPricePerL',
|
||||||
'packages',
|
'packages',
|
||||||
'planes',
|
'planes',
|
||||||
|
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
|
||||||
'sellFraction',
|
'sellFraction',
|
||||||
'simState',
|
'simState',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ describe('POST /api/planes/:planeId/fly', () => {
|
|||||||
'fuelPricePerL',
|
'fuelPricePerL',
|
||||||
'packages',
|
'packages',
|
||||||
'planes',
|
'planes',
|
||||||
|
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
|
||||||
'sellFraction',
|
'sellFraction',
|
||||||
'simState',
|
'simState',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ describe('GET /api/state', () => {
|
|||||||
'fuelPricePerL',
|
'fuelPricePerL',
|
||||||
'packages',
|
'packages',
|
||||||
'planes',
|
'planes',
|
||||||
|
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
|
||||||
'sellFraction',
|
'sellFraction',
|
||||||
'simState',
|
'simState',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -737,6 +737,32 @@ body {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* v1.6.0 (ISS-115) — leaderboard panel inside the generic modal, same table look as .admin-rows. */
|
||||||
|
.leaderboard {
|
||||||
|
padding: 14px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard th,
|
||||||
|
.leaderboard td {
|
||||||
|
padding: 4px 6px;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard th {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
/* v1.1.0 (ISS-107) — full-screen sign-in / create-account screen, same dark slate theme as the panes. */
|
/* v1.1.0 (ISS-107) — full-screen sign-in / create-account screen, same dark slate theme as the panes. */
|
||||||
.sign-in {
|
.sign-in {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { formatCash } from '@/lib/format';
|
||||||
|
import type { PlayerSummary } from '@/lib/game/state';
|
||||||
|
|
||||||
|
// v1.6.0 (ISS-115): leaderboard panel — every player in the world with cash, reputation and
|
||||||
|
// plane count, rendered straight off the shared snapshot (no endpoint of its own). Sorted by
|
||||||
|
// reputation desc; cash then username break ties deterministically so rows don't shuffle
|
||||||
|
// between snapshots when two players share a reputation.
|
||||||
|
export default function Leaderboard({ players }: { players: PlayerSummary[] }) {
|
||||||
|
const sorted = [...players].sort(
|
||||||
|
(a, b) => b.reputation - a.reputation || b.cash - a.cash || a.username.localeCompare(b.username),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="leaderboard">
|
||||||
|
<table data-testid="leaderboard-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Player</th>
|
||||||
|
<th>Cash</th>
|
||||||
|
<th>Reputation</th>
|
||||||
|
<th>Planes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sorted.map((p) => (
|
||||||
|
<tr key={p.id} data-testid={`leaderboard-row-${p.id}`}>
|
||||||
|
<td>{p.username}</td>
|
||||||
|
<td>{formatCash(p.cash)}</td>
|
||||||
|
<td>{p.reputation}</td>
|
||||||
|
<td>{p.planeCount}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { weatherFor } from '../lib/weather';
|
|||||||
import { approxLocalTime } from '../lib/localTime';
|
import { approxLocalTime } from '../lib/localTime';
|
||||||
import Modal from './Modal';
|
import Modal from './Modal';
|
||||||
import AdminPanel from './AdminPanel';
|
import AdminPanel from './AdminPanel';
|
||||||
|
import Leaderboard from './Leaderboard';
|
||||||
import { sessionHeaders } from '../lib/authClient';
|
import { sessionHeaders } from '../lib/authClient';
|
||||||
|
|
||||||
// v0.70.0 default view: null island [0,0] at zoom 2 — the world is 2048 px wide there and the
|
// v0.70.0 default view: null island [0,0] at zoom 2 — the world is 2048 px wide there and the
|
||||||
@@ -98,6 +99,8 @@ export default function FlightMap({ state, hovered, applySnapshot, onLogout }: M
|
|||||||
// v0.71.0: the Admin modal (top-bar button) — unmounted while closed so it re-checks the
|
// v0.71.0: the Admin modal (top-bar button) — unmounted while closed so it re-checks the
|
||||||
// stored session on every open.
|
// stored session on every open.
|
||||||
const [adminOpen, setAdminOpen] = useState(false);
|
const [adminOpen, setAdminOpen] = useState(false);
|
||||||
|
// v1.6.0 (ISS-115): the leaderboard panel (top-bar button) — rows come from the polled snapshot.
|
||||||
|
const [leaderboardOpen, setLeaderboardOpen] = useState(false);
|
||||||
|
|
||||||
// Push the latest planes/routes GeoJSON into whatever sources currently exist.
|
// Push the latest planes/routes GeoJSON into whatever sources currently exist.
|
||||||
// No-op until the map (and for routes: the airport index) is ready — the mount
|
// No-op until the map (and for routes: the airport index) is ready — the mount
|
||||||
@@ -589,6 +592,10 @@ export default function FlightMap({ state, hovered, applySnapshot, onLogout }: M
|
|||||||
<span data-testid="airport-count">
|
<span data-testid="airport-count">
|
||||||
{airportCount === null ? 'loading airports…' : `${airportCount} world airports`}
|
{airportCount === null ? 'loading airports…' : `${airportCount} world airports`}
|
||||||
</span>
|
</span>
|
||||||
|
{/* v1.6.0 (ISS-115): leaderboard panel entry point — right side of the top bar, opens a modal. */}
|
||||||
|
<button type="button" data-testid="leaderboard-btn" className="pane-btn" onClick={() => setLeaderboardOpen(true)}>
|
||||||
|
Leaderboard
|
||||||
|
</button>
|
||||||
{/* v0.71.0: admin panel entry point — right side of the top bar, opens a modal (no URL). */}
|
{/* v0.71.0: admin panel entry point — right side of the top bar, opens a modal (no URL). */}
|
||||||
<button type="button" data-testid="admin-btn" className="pane-btn" onClick={() => setAdminOpen(true)}>
|
<button type="button" data-testid="admin-btn" className="pane-btn" onClick={() => setAdminOpen(true)}>
|
||||||
Admin
|
Admin
|
||||||
@@ -598,6 +605,10 @@ export default function FlightMap({ state, hovered, applySnapshot, onLogout }: M
|
|||||||
<Modal open={adminOpen} onClose={() => setAdminOpen(false)} title="Admin">
|
<Modal open={adminOpen} onClose={() => setAdminOpen(false)} title="Admin">
|
||||||
<AdminPanel onLogout={onLogout} />
|
<AdminPanel onLogout={onLogout} />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
{/* v1.6.0 (ISS-115): leaderboard — players from the shared snapshot, no extra endpoint. */}
|
||||||
|
<Modal open={leaderboardOpen} onClose={() => setLeaderboardOpen(false)} title="Leaderboard">
|
||||||
|
<Leaderboard players={state?.players ?? []} />
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ function snap(planes: PlaneState[], packages: PackageState[] = []): StateSnapsho
|
|||||||
account: { cash: 0, reputation: 0 },
|
account: { cash: 0, reputation: 0 },
|
||||||
simState: { clockMs: 0 },
|
simState: { clockMs: 0 },
|
||||||
planes,
|
planes,
|
||||||
|
players: [], // v1.6.0 (ISS-115) — leaderboard rows; unused by the right pane
|
||||||
packages,
|
packages,
|
||||||
catalog: CATALOG,
|
catalog: CATALOG,
|
||||||
fuelPricePerL: 0.8,
|
fuelPricePerL: 0.8,
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ const snapshot = (over: Partial<StateSnapshot> = {}): StateSnapshot => ({
|
|||||||
account: { cash: 20_000, reputation: 10 },
|
account: { cash: 20_000, reputation: 10 },
|
||||||
simState: { clockMs: 0 },
|
simState: { clockMs: 0 },
|
||||||
planes: [basePlane('plane-starter')],
|
planes: [basePlane('plane-starter')],
|
||||||
|
players: [], // v1.6.0 (ISS-115) — leaderboard rows; unused by the event diffing
|
||||||
packages: [],
|
packages: [],
|
||||||
catalog: CATALOG,
|
catalog: CATALOG,
|
||||||
fuelPricePerL: 0.8,
|
fuelPricePerL: 0.8,
|
||||||
|
|||||||
+55
-2
@@ -1,5 +1,12 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import fs from 'node:fs/promises';
|
||||||
import { SSE_PACKAGE_STATUSES, sseFrame, type PackageState, type StateSnapshot } from './state';
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import * as schema from '@/lib/db/schema';
|
||||||
|
import { getGameDb } from '@/lib/db/client';
|
||||||
|
import { PLANE_TYPES } from './catalog';
|
||||||
|
import { STARTING_CASH, STARTING_REPUTATION } from './seed';
|
||||||
|
import { getStateSnapshot, SSE_PACKAGE_STATUSES, sseFrame, type PackageState, type StateSnapshot } from './state';
|
||||||
|
|
||||||
// ISS-133 (v0.59.0) — the SSE projection keeps only the package statuses any component renders.
|
// ISS-133 (v0.59.0) — the SSE projection keeps only the package statuses any component renders.
|
||||||
|
|
||||||
@@ -30,6 +37,7 @@ const snapshot = (packages: PackageState[]): StateSnapshot => ({
|
|||||||
fuelPricePerL: 0.8,
|
fuelPricePerL: 0.8,
|
||||||
sellFraction: 0.5,
|
sellFraction: 0.5,
|
||||||
planes: [],
|
planes: [],
|
||||||
|
players: [], // v1.6.0 (ISS-115) — leaderboard rows; empty in these sseFrame fixtures
|
||||||
packages,
|
packages,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,3 +68,48 @@ describe('sseFrame', () => {
|
|||||||
expect([...SSE_PACKAGE_STATUSES].sort()).toEqual(['expired', 'loaded', 'open']);
|
expect([...SSE_PACKAGE_STATUSES].sort()).toEqual(['expired', 'loaded', 'open']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// v1.6.0 (ISS-115) — the snapshot's players array: one row per player with cash/reputation and
|
||||||
|
// how many planes they own. Runs against a throwaway seeded DB via the app singleton, exactly
|
||||||
|
// like the /api/state route tests (fresh module registry per file ⇒ fresh singleton).
|
||||||
|
describe('getStateSnapshot players', () => {
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-snapshot-players-'));
|
||||||
|
process.env.FLIGHT_DB_PATH = path.join(dir, 'players.db');
|
||||||
|
await getGameDb(); // seeds the local player + starter plane
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
delete process.env.FLIGHT_DB_PATH;
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('single-player regression guard — one row for the seeded local account', async () => {
|
||||||
|
const snap = await getStateSnapshot('local');
|
||||||
|
expect(snap.players).toEqual([
|
||||||
|
{ id: 'local', username: 'kamilkrzywda', cash: STARTING_CASH, reputation: STARTING_REPUTATION, planeCount: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('two players — cash/reputation/planeCount per owner (v1.6.0, ISS-115)', async () => {
|
||||||
|
const inst = await getGameDb();
|
||||||
|
// A second account with a distinct economy and two planes of its own — rows minted like /api/register does.
|
||||||
|
await inst.db.insert(schema.players).values({ id: 'b', provider: 'local', username: 'player-b', cash: 5_000, reputation: 3 });
|
||||||
|
const type = PLANE_TYPES.atr72;
|
||||||
|
for (const id of ['plane-b-1', 'plane-b-2']) {
|
||||||
|
await inst.db.insert(schema.planes).values({
|
||||||
|
id, ownerId: 'b', model: 'atr72', status: 'available',
|
||||||
|
fuel: type.maxFuel, maxFuel: type.maxFuel, locationIcao: 'EDDF', lat: 50.03, lon: 8.56,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const snap = await getStateSnapshot('b'); // any session sees the whole world's players
|
||||||
|
expect(snap.players).toHaveLength(2);
|
||||||
|
const local = snap.players.find((p) => p.id === 'local')!;
|
||||||
|
expect(local).toEqual({ id: 'local', username: 'kamilkrzywda', cash: STARTING_CASH, reputation: STARTING_REPUTATION, planeCount: 1 }); // starter still counted once…
|
||||||
|
const b = snap.players.find((p) => p.id === 'b')!;
|
||||||
|
expect(b).toEqual({ id: 'b', username: 'player-b', cash: 5_000, reputation: 3, planeCount: 2 }); // …and both of B's planes count for B.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -97,12 +97,25 @@ export interface PackageState {
|
|||||||
carrierName: string | null;
|
carrierName: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.6.0 (ISS-115): per-player summary for the leaderboard panel — rides on every snapshot
|
||||||
|
// (GET /api/state + SSE frames), so no new endpoint is needed. planeCount = planes owned.
|
||||||
|
export interface PlayerSummary {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
cash: number;
|
||||||
|
reputation: number;
|
||||||
|
planeCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StateSnapshot {
|
export interface StateSnapshot {
|
||||||
account: { cash: number; reputation: number };
|
account: { cash: number; reputation: number };
|
||||||
// ISS-117 (v0.52.0): the world rate is a fixed constant (WORLD_TIME_SCALE), not per-save state —
|
// ISS-117 (v0.52.0): the world rate is a fixed constant (WORLD_TIME_SCALE), not per-save state —
|
||||||
// only the clock itself is reported here.
|
// only the clock itself is reported here.
|
||||||
simState: { clockMs: number };
|
simState: { clockMs: number };
|
||||||
planes: PlaneState[];
|
planes: PlaneState[];
|
||||||
|
// v1.6.0 (ISS-115): leaderboard data — one row per player, projected from the same players +
|
||||||
|
// planes reads as everything else in this snapshot (no extra query).
|
||||||
|
players: PlayerSummary[];
|
||||||
packages: PackageState[];
|
packages: PackageState[];
|
||||||
// Market reference data (v0.10.0) — static per build, sent with every snapshot so
|
// Market reference data (v0.10.0) — static per build, sent with every snapshot so
|
||||||
// the client can compute buy/sell previews without a separate endpoint.
|
// the client can compute buy/sell previews without a separate endpoint.
|
||||||
@@ -166,6 +179,10 @@ export async function getStateSnapshot(playerId: string): Promise<StateSnapshot>
|
|||||||
const usernameByPlayerId = new Map(playerRows.map((pl) => [pl.id, pl.username]));
|
const usernameByPlayerId = new Map(playerRows.map((pl) => [pl.id, pl.username]));
|
||||||
const planeById = new Map(planeRows.map((p) => [p.id, p]));
|
const planeById = new Map(planeRows.map((p) => [p.id, p]));
|
||||||
|
|
||||||
|
// v1.6.0 (ISS-115): leaderboard — count owned planes per player from the rows already loaded above.
|
||||||
|
const planeCountByOwner = new Map<string, number>();
|
||||||
|
for (const p of planeRows) planeCountByOwner.set(p.ownerId, (planeCountByOwner.get(p.ownerId) ?? 0) + 1);
|
||||||
|
|
||||||
// Analytic movement (v0.7.0): a flying plane's stored lat/lon/fuel are its
|
// Analytic movement (v0.7.0): a flying plane's stored lat/lon/fuel are its
|
||||||
// departure values — live position and fuel are pure functions of the clock.
|
// departure values — live position and fuel are pure functions of the clock.
|
||||||
const livePlane = (p: schema.PlaneRow) => {
|
const livePlane = (p: schema.PlaneRow) => {
|
||||||
@@ -218,6 +235,14 @@ export async function getStateSnapshot(playerId: string): Promise<StateSnapshot>
|
|||||||
ownerName: usernameByPlayerId.get(p.ownerId) ?? p.ownerId, // missing players row → raw id, never a crash
|
ownerName: usernameByPlayerId.get(p.ownerId) ?? p.ownerId, // missing players row → raw id, never a crash
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
// v1.6.0 (ISS-115): leaderboard rows — every player in the world with their economy + fleet size.
|
||||||
|
players: playerRows.map((pl) => ({
|
||||||
|
id: pl.id,
|
||||||
|
username: pl.username,
|
||||||
|
cash: pl.cash,
|
||||||
|
reputation: pl.reputation,
|
||||||
|
planeCount: planeCountByOwner.get(pl.id) ?? 0,
|
||||||
|
})),
|
||||||
packages: packageRows.filter((p) => p.status !== 'open' || pool.has(p.originIcao)).map((p) => ({
|
packages: packageRows.filter((p) => p.status !== 'open' || pool.has(p.originIcao)).map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
originIcao: p.originIcao,
|
originIcao: p.originIcao,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ function snapshot(overrides: Partial<StateSnapshot> = {}): StateSnapshot {
|
|||||||
account: { cash: 0, reputation: 0 },
|
account: { cash: 0, reputation: 0 },
|
||||||
simState: { clockMs: 0 },
|
simState: { clockMs: 0 },
|
||||||
planes: [],
|
planes: [],
|
||||||
|
players: [], // v1.6.0 (ISS-115) — leaderboard rows; unused by the map projections
|
||||||
packages: [],
|
packages: [],
|
||||||
catalog: [
|
catalog: [
|
||||||
{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9_300, speedKmh: 510, maxPayloadKg: 4_800 },
|
{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9_300, speedKmh: 510, maxPayloadKg: 4_800 },
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ describe('OpenAPI spec', () => {
|
|||||||
'fuelPricePerL',
|
'fuelPricePerL',
|
||||||
'packages',
|
'packages',
|
||||||
'planes',
|
'planes',
|
||||||
|
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
|
||||||
'sellFraction',
|
'sellFraction',
|
||||||
'simState',
|
'simState',
|
||||||
]);
|
]);
|
||||||
@@ -86,12 +87,14 @@ describe('OpenAPI spec', () => {
|
|||||||
'fuelPricePerL',
|
'fuelPricePerL',
|
||||||
'packages',
|
'packages',
|
||||||
'planes',
|
'planes',
|
||||||
|
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
|
||||||
'sellFraction',
|
'sellFraction',
|
||||||
'simState',
|
'simState',
|
||||||
]);
|
]);
|
||||||
expect(snap.account.$ref).toBe('#/components/schemas/AccountState');
|
expect(snap.account.$ref).toBe('#/components/schemas/AccountState');
|
||||||
expect(snap.simState.$ref).toBe('#/components/schemas/SimState');
|
expect(snap.simState.$ref).toBe('#/components/schemas/SimState');
|
||||||
expect(snap.planes.items.$ref).toBe('#/components/schemas/PlaneState');
|
expect(snap.planes.items.$ref).toBe('#/components/schemas/PlaneState');
|
||||||
|
expect(snap.players.items.$ref).toBe('#/components/schemas/PlayerSummary'); // v1.6.0 (ISS-115)
|
||||||
expect(snap.packages.items.$ref).toBe('#/components/schemas/PackageState');
|
expect(snap.packages.items.$ref).toBe('#/components/schemas/PackageState');
|
||||||
expect(snap.catalog.items.$ref).toBe('#/components/schemas/CatalogEntry');
|
expect(snap.catalog.items.$ref).toBe('#/components/schemas/CatalogEntry');
|
||||||
expect(snap.fuelPricePerL.type).toBe('number');
|
expect(snap.fuelPricePerL.type).toBe('number');
|
||||||
|
|||||||
+18
-1
@@ -707,6 +707,11 @@ export const openApiSpec = {
|
|||||||
type: 'array',
|
type: 'array',
|
||||||
items: { $ref: '#/components/schemas/PlaneState' },
|
items: { $ref: '#/components/schemas/PlaneState' },
|
||||||
},
|
},
|
||||||
|
players: {
|
||||||
|
type: 'array',
|
||||||
|
description: 'Leaderboard rows (v1.6.0, ISS-115) — one per player in the world.',
|
||||||
|
items: { $ref: '#/components/schemas/PlayerSummary' },
|
||||||
|
},
|
||||||
packages: {
|
packages: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
items: { $ref: '#/components/schemas/PackageState' },
|
items: { $ref: '#/components/schemas/PackageState' },
|
||||||
@@ -719,7 +724,7 @@ export const openApiSpec = {
|
|||||||
fuelPricePerL: { type: 'number', description: 'Jet-A1 price in €/liter; the automatic dispatch top-up costs liters × this, reduced by a reputation-scaled discount at refuel time (v0.38.0) — ISS-082 (v0.42.0): it now applies to the auto-refuel on /fly and the engine\'s chained-leg top-ups.' },
|
fuelPricePerL: { type: 'number', description: 'Jet-A1 price in €/liter; the automatic dispatch top-up costs liters × this, reduced by a reputation-scaled discount at refuel time (v0.38.0) — ISS-082 (v0.42.0): it now applies to the auto-refuel on /fly and the engine\'s chained-leg top-ups.' },
|
||||||
sellFraction: { type: 'number', description: 'Fraction of the catalog price paid out when selling a plane.' },
|
sellFraction: { type: 'number', description: 'Fraction of the catalog price paid out when selling a plane.' },
|
||||||
},
|
},
|
||||||
required: ['account', 'simState', 'planes', 'packages', 'catalog', 'fuelPricePerL', 'sellFraction'],
|
required: ['account', 'simState', 'planes', 'players', 'packages', 'catalog', 'fuelPricePerL', 'sellFraction'],
|
||||||
},
|
},
|
||||||
CatalogEntry: {
|
CatalogEntry: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -750,6 +755,18 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
required: ['clockMs'],
|
required: ['clockMs'],
|
||||||
},
|
},
|
||||||
|
PlayerSummary: {
|
||||||
|
type: 'object',
|
||||||
|
description: 'One leaderboard row (v1.6.0, ISS-115) — a player\'s economy plus how many planes they own.',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
username: { type: 'string' },
|
||||||
|
cash: { type: 'number' },
|
||||||
|
reputation: { type: 'number' },
|
||||||
|
planeCount: { type: 'integer', description: 'Number of planes in the world owned by this player.' },
|
||||||
|
},
|
||||||
|
required: ['id', 'username', 'cash', 'reputation', 'planeCount'],
|
||||||
|
},
|
||||||
LoadPackageRequest: {
|
LoadPackageRequest: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flight-ops",
|
"name": "flight-ops",
|
||||||
"version": "1.5.0",
|
"version": "1.6.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
Reference in New Issue
Block a user