v1.6.0: leaderboard pane from the top-bar additional-panels button (ISS-115) #160
@@ -1,5 +1,11 @@
|
||||
# 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
|
||||
- **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.
|
||||
|
||||
@@ -111,6 +111,7 @@ describe('POST /api/packages/:id/abandon', () => {
|
||||
'fuelPricePerL',
|
||||
'packages',
|
||||
'planes',
|
||||
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
|
||||
'sellFraction',
|
||||
'simState',
|
||||
]);
|
||||
|
||||
@@ -111,6 +111,7 @@ describe('POST /api/packages/:id/load', () => {
|
||||
'fuelPricePerL',
|
||||
'packages',
|
||||
'planes',
|
||||
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
|
||||
'sellFraction',
|
||||
'simState',
|
||||
]);
|
||||
|
||||
@@ -102,6 +102,7 @@ describe('POST /api/planes/:planeId/fly', () => {
|
||||
'fuelPricePerL',
|
||||
'packages',
|
||||
'planes',
|
||||
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
|
||||
'sellFraction',
|
||||
'simState',
|
||||
]);
|
||||
|
||||
@@ -46,6 +46,7 @@ describe('GET /api/state', () => {
|
||||
'fuelPricePerL',
|
||||
'packages',
|
||||
'planes',
|
||||
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
|
||||
'sellFraction',
|
||||
'simState',
|
||||
]);
|
||||
|
||||
@@ -737,6 +737,32 @@ body {
|
||||
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. */
|
||||
.sign-in {
|
||||
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 Modal from './Modal';
|
||||
import AdminPanel from './AdminPanel';
|
||||
import Leaderboard from './Leaderboard';
|
||||
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
|
||||
@@ -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
|
||||
// stored session on every open.
|
||||
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.
|
||||
// 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">
|
||||
{airportCount === null ? 'loading airports…' : `${airportCount} world airports`}
|
||||
</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). */}
|
||||
<button type="button" data-testid="admin-btn" className="pane-btn" onClick={() => setAdminOpen(true)}>
|
||||
Admin
|
||||
@@ -598,6 +605,10 @@ export default function FlightMap({ state, hovered, applySnapshot, onLogout }: M
|
||||
<Modal open={adminOpen} onClose={() => setAdminOpen(false)} title="Admin">
|
||||
<AdminPanel onLogout={onLogout} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ function snap(planes: PlaneState[], packages: PackageState[] = []): StateSnapsho
|
||||
account: { cash: 0, reputation: 0 },
|
||||
simState: { clockMs: 0 },
|
||||
planes,
|
||||
players: [], // v1.6.0 (ISS-115) — leaderboard rows; unused by the right pane
|
||||
packages,
|
||||
catalog: CATALOG,
|
||||
fuelPricePerL: 0.8,
|
||||
|
||||
@@ -53,6 +53,7 @@ const snapshot = (over: Partial<StateSnapshot> = {}): StateSnapshot => ({
|
||||
account: { cash: 20_000, reputation: 10 },
|
||||
simState: { clockMs: 0 },
|
||||
planes: [basePlane('plane-starter')],
|
||||
players: [], // v1.6.0 (ISS-115) — leaderboard rows; unused by the event diffing
|
||||
packages: [],
|
||||
catalog: CATALOG,
|
||||
fuelPricePerL: 0.8,
|
||||
|
||||
+55
-2
@@ -1,5 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SSE_PACKAGE_STATUSES, sseFrame, type PackageState, type StateSnapshot } from './state';
|
||||
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 * 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.
|
||||
|
||||
@@ -30,6 +37,7 @@ const snapshot = (packages: PackageState[]): StateSnapshot => ({
|
||||
fuelPricePerL: 0.8,
|
||||
sellFraction: 0.5,
|
||||
planes: [],
|
||||
players: [], // v1.6.0 (ISS-115) — leaderboard rows; empty in these sseFrame fixtures
|
||||
packages,
|
||||
});
|
||||
|
||||
@@ -60,3 +68,48 @@ describe('sseFrame', () => {
|
||||
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;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
account: { cash: number; reputation: number };
|
||||
// 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.
|
||||
simState: { clockMs: number };
|
||||
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[];
|
||||
// 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.
|
||||
@@ -166,6 +179,10 @@ export async function getStateSnapshot(playerId: string): Promise<StateSnapshot>
|
||||
const usernameByPlayerId = new Map(playerRows.map((pl) => [pl.id, pl.username]));
|
||||
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
|
||||
// departure values — live position and fuel are pure functions of the clock.
|
||||
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
|
||||
};
|
||||
}),
|
||||
// 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) => ({
|
||||
id: p.id,
|
||||
originIcao: p.originIcao,
|
||||
|
||||
@@ -36,6 +36,7 @@ function snapshot(overrides: Partial<StateSnapshot> = {}): StateSnapshot {
|
||||
account: { cash: 0, reputation: 0 },
|
||||
simState: { clockMs: 0 },
|
||||
planes: [],
|
||||
players: [], // v1.6.0 (ISS-115) — leaderboard rows; unused by the map projections
|
||||
packages: [],
|
||||
catalog: [
|
||||
{ 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',
|
||||
'packages',
|
||||
'planes',
|
||||
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
|
||||
'sellFraction',
|
||||
'simState',
|
||||
]);
|
||||
@@ -86,12 +87,14 @@ describe('OpenAPI spec', () => {
|
||||
'fuelPricePerL',
|
||||
'packages',
|
||||
'planes',
|
||||
'players', // v1.6.0 (ISS-115) — leaderboard rows ride on every snapshot
|
||||
'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.players.items.$ref).toBe('#/components/schemas/PlayerSummary'); // v1.6.0 (ISS-115)
|
||||
expect(snap.packages.items.$ref).toBe('#/components/schemas/PackageState');
|
||||
expect(snap.catalog.items.$ref).toBe('#/components/schemas/CatalogEntry');
|
||||
expect(snap.fuelPricePerL.type).toBe('number');
|
||||
|
||||
+18
-1
@@ -707,6 +707,11 @@ export const openApiSpec = {
|
||||
type: 'array',
|
||||
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: {
|
||||
type: 'array',
|
||||
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.' },
|
||||
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: {
|
||||
type: 'object',
|
||||
@@ -750,6 +755,18 @@ export const openApiSpec = {
|
||||
},
|
||||
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: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flight-ops",
|
||||
"version": "1.5.0",
|
||||
"version": "1.6.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
|
||||
Reference in New Issue
Block a user