diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0a84a33..dfb26f0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/app/api/packages/[id]/abandon/route.test.ts b/app/api/packages/[id]/abandon/route.test.ts
index 7822825..9bf117c 100644
--- a/app/api/packages/[id]/abandon/route.test.ts
+++ b/app/api/packages/[id]/abandon/route.test.ts
@@ -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',
]);
diff --git a/app/api/packages/[id]/load/route.test.ts b/app/api/packages/[id]/load/route.test.ts
index cbc88ee..cf2e991 100644
--- a/app/api/packages/[id]/load/route.test.ts
+++ b/app/api/packages/[id]/load/route.test.ts
@@ -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',
]);
diff --git a/app/api/planes/[planeId]/fly/route.test.ts b/app/api/planes/[planeId]/fly/route.test.ts
index d1fa2a8..c758d1d 100644
--- a/app/api/planes/[planeId]/fly/route.test.ts
+++ b/app/api/planes/[planeId]/fly/route.test.ts
@@ -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',
]);
diff --git a/app/api/state/route.test.ts b/app/api/state/route.test.ts
index dab4ca7..b24ce95 100644
--- a/app/api/state/route.test.ts
+++ b/app/api/state/route.test.ts
@@ -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',
]);
diff --git a/app/globals.css b/app/globals.css
index 397f498..c05ba29 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -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;
diff --git a/components/Leaderboard.tsx b/components/Leaderboard.tsx
new file mode 100644
index 0000000..1005eea
--- /dev/null
+++ b/components/Leaderboard.tsx
@@ -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 (
+
+
+
+
+
Player
+
Cash
+
Reputation
+
Planes
+
+
+
+ {sorted.map((p) => (
+
+
{p.username}
+
{formatCash(p.cash)}
+
{p.reputation}
+
{p.planeCount}
+
+ ))}
+
+
+
+ );
+}
diff --git a/components/Map.tsx b/components/Map.tsx
index b884d4b..92d8ac3 100644
--- a/components/Map.tsx
+++ b/components/Map.tsx
@@ -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
{airportCount === null ? 'loading airports…' : `${airportCount} world airports`}
+ {/* v1.6.0 (ISS-115): leaderboard panel entry point — right side of the top bar, opens a modal. */}
+
{/* v0.71.0: admin panel entry point — right side of the top bar, opens a modal (no URL). */}