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 ( +
+ + + + + + + + + + + {sorted.map((p) => ( + + + + + + + ))} + +
PlayerCashReputationPlanes
{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). */}