Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b60006a949 | ||
|
|
fe84d9a0bb | ||
|
|
43c4bb97a9 | ||
|
|
b04e504263 | ||
|
|
d87d848ca2 | ||
|
|
eae680c002 |
@@ -1,5 +1,22 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.0 — 2026-09-26 20:24
|
||||
- **Multiplayer step 12/12 (ISS-116): multiplayer test coverage.** The final campaign step closes the two remaining verification gaps with tests only — no production game code changed: the #135 freeze rule (`shouldAdvanceTime`) already counts ALL planes regardless of owner, and the mint hook has supported a username override since v1.1.0 (the e2e helper just never forwarded it).
|
||||
- **E2E.** New `e2e/multiplayer.spec.ts` (serial) plays two live sessions: player A is the suite's usual local player; player B is a second account minted through the same hook with a username override. Test 1 verifies the N-player clock end-to-end (#135 × ISS-113): while A flies and B stays grounded, the world clock advances on every read (one owner airborne is enough to keep time moving), then after A lands — EVERY player grounded — the clock freezes exactly across ~2.5 s of real idleness. Test 2 verifies ownership isolation: both sessions see the same shared fleet with `ownerId`/`ownerName`, A gets 403 "not your plane" on B's starter (fly AND sell — the 403 must beat B's last-plane 409), B gets 403 on A's, a grant to local moves only A's account row while B's stays untouched, and both sessions see the v1.6.0 `players` summary with per-owner plane counts.
|
||||
- **Test hooks.** `e2e/auth.ts`'s `mintPlayerSession(page)` now forwards an optional username (the route already supported it). New FLIGHT_TEST_HOOKS-gated `POST /api/test/delete-plane {username}` — deletes every plane owned by that player's row (any bound packages unbound first), idempotent for unknown usernames, returns the local-scoped snapshot like its siblings. Needed because B's onboarding starter is unsellable (per-owner last-plane guard) and would break later specs' single-fleet self-heal within the same CI run; an `afterAll` deletes it even when tests fail.
|
||||
- **Tests.** Unit: two new engine tests — a tickOnce-level N-player clock test (A flying + B grounded → advances at exactly the fixed world rate; all owners grounded → frozen with the real-time anchor still tracking, ISS-113) and the symmetric spawn-quality case (local leads → MAX picks local's row, not "prefer non-local"). No existing e2e spec changed: they are additive-safe against a second player.
|
||||
|
||||
## 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.
|
||||
- **Tests.** Unit: two new engine tests in the `spawnPoolPackages` suite — with a second player at rep 60 vs local's starting rep, every spawned package's reward matches an independent recomputation (rewardFor × rep bonus × special multiplier) at the MAX and provably not at local's; single-player regression guard asserts one row ⇒ quality is that row's own reputation, identical to the pre-v1.5.0 singleton read. No e2e spec changes: CI plays a single minted player, where MAX equals that player's rep — behavior unchanged.
|
||||
|
||||
## v1.4.0 — 2026-09-26 18:35
|
||||
- **Multiplayer step 8/12 (ISS-112): per-player snapshot + shared market view with owner info.** The state snapshot is now a true SHARED-WORLD view on every surface that serves it. `getStateSnapshot(playerId)` in `lib/game/state.ts` gains two additive fields: every plane row carries `ownerId` + `ownerName` (players.username via planes.owner_id — the snapshot lists ALL planes, so panes can tell whose plane is whose), and every package row carries `carrierName` (players.username of the carrying plane's owner, joined package → planeId → planes.owner_id → players; null when unassigned). One players-table read per projection serves both joins. `account` stays the acting session player's row (v1.3.0) — no renames or removals anywhere, so existing clients and e2e assertions keep working unchanged.
|
||||
- **SSE frames are now PER PLAYER.** The #101 decision point is resolved: visibility of other players' planes is ON by default — there is no toggle (a shared world you can't see in is pointless; nobody asked for a privacy switch). `lib/sse.ts` keys its subscriber registry by player id (`subscribe(playerId, fn)`, new `subscribedPlayerIds()`), and the engine's tick broadcast (`broadcastState` in `lib/sim/engine.ts`) projects ONE snapshot per connected session's player id — N clients = N projections per tick, N is tiny — fanning each frame out to that player's group only. Every client now sees its own account row in every pushed frame instead of the seeded local player's. The connect frame and all pushes come from the SAME shared projection (`getStateSnapshot` + `sseFrame`), as does GET /api/state, so no two surfaces can drift; `/api/events` subscribes under `auth.player.id`.
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomInt, randomUUID } from 'node:crypto';
|
||||
import { getGameDb, persist } from '@/lib/db/client';
|
||||
import { getLocalPlayer } from '@/lib/db/localPlayer';
|
||||
import { getSharedWorldReputation } from '@/lib/db/localPlayer';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||
import { generatePackage, mulberry32 } from '@/lib/game/seed';
|
||||
@@ -83,9 +83,9 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
const rng = mulberry32(randomInt(0, 2 ** 31));
|
||||
// v0.38.0 (ISS-072): hook-created packages carry the LIVE account reputation — same job quality
|
||||
// as engine spawns; never force a fixed value here.
|
||||
const player = await getLocalPlayer(inst);
|
||||
// v0.38.0 (ISS-072): hook-created packages carry shared-world job quality — the same MAX-reputation
|
||||
// rule as engine spawns (v1.5.0, ISS-111); never force a fixed value here.
|
||||
const reputation = await getSharedWorldReputation(inst);
|
||||
const created = generatePackage({
|
||||
id: randomUUID(),
|
||||
airports,
|
||||
@@ -97,7 +97,7 @@ export async function POST(req: Request) {
|
||||
weightKg: input.weightKg as number | undefined,
|
||||
// Omitted → force null (no roll) so existing e2e reward/rep assertions stay deterministic.
|
||||
special: input.special === undefined ? null : (input.special as string | null),
|
||||
reputation: player.reputation,
|
||||
reputation,
|
||||
});
|
||||
await inst.db.insert(schema.packages).values(created);
|
||||
await persist(inst);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getGameDb, persist } from '@/lib/db/client';
|
||||
import { LOCAL_PLAYER } from '@/lib/db/localPlayer';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { getStateSnapshot } from '@/lib/game/state';
|
||||
import { tickNow } from '@/lib/sim/engine';
|
||||
|
||||
// TEST-ONLY hook (v1.7.0, ISS-116) — deletes every plane owned by a player so the e2e suite can
|
||||
// remove planes no API path can get rid of: a minted second account's onboarding starter is its
|
||||
// LAST plane, and the per-owner last-plane guard (v1.2.0, ISS-109) makes it unsellable. Leftover
|
||||
// foreign planes would break later specs' single-fleet self-heal (two-planes.spec.ts sells every
|
||||
// non-starter as local → 403). Disabled unless FLIGHT_TEST_HOOKS=1; sibling of /api/test/set-fuel —
|
||||
// same gate, same conventions.
|
||||
//
|
||||
// POST /api/test/delete-plane
|
||||
// body { username: string } — required. Deletes all planes owned by that player's row (any bound
|
||||
// packages are unbound first) and returns the full state snapshot after ticking, like siblings.
|
||||
// Idempotent: an unknown username deletes nothing and still 200s, so cleanup can run unconditionally.
|
||||
|
||||
export async function POST(req: Request) {
|
||||
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
|
||||
return Response.json({ error: 'test hooks disabled' }, { status: 404 });
|
||||
}
|
||||
|
||||
let body: unknown = {};
|
||||
try {
|
||||
const text = await req.text();
|
||||
body = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
||||
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
|
||||
}
|
||||
const input = body as Record<string, unknown>;
|
||||
for (const key of Object.keys(input)) {
|
||||
if (key !== 'username') {
|
||||
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
||||
}
|
||||
}
|
||||
const username = input.username;
|
||||
if (typeof username !== 'string' || username.length === 0) {
|
||||
return Response.json({ error: 'username is required and must be a non-empty string' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const inst = await getGameDb();
|
||||
await tickNow(); // clock is current before the snapshot we return (same as siblings)
|
||||
const players = await inst.db.select().from(schema.players).where(eq(schema.players.username, username));
|
||||
let deleted = 0;
|
||||
if (players[0]) {
|
||||
const owned = await inst.db.select().from(schema.planes).where(eq(schema.planes.ownerId, players[0].id));
|
||||
for (const plane of owned) {
|
||||
// Free any bound packages' slots — a deleted plane must never leave dangling planeIds.
|
||||
await inst.db.update(schema.packages).set({ planeId: null }).where(eq(schema.packages.planeId, plane.id));
|
||||
await inst.db.delete(schema.planes).where(eq(schema.planes.id, plane.id));
|
||||
deleted++;
|
||||
}
|
||||
}
|
||||
if (deleted > 0) {
|
||||
await persist(inst);
|
||||
}
|
||||
// v1.3.0 (ISS-110): admin-style hook — no session; the snapshot reports the seeded local account.
|
||||
return Response.json({ deleted, ...(await getStateSnapshot(LOCAL_PLAYER.id)) });
|
||||
} catch (err) {
|
||||
console.error('test hook failed', err);
|
||||
return Response.json({ error: 'test hook failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+5
-2
@@ -9,8 +9,11 @@ import { expect } from '@playwright/test';
|
||||
|
||||
export const SESSION_KEY = 'flight.session'; // must match lib/authClient.ts
|
||||
|
||||
export async function mintPlayerSession(page: Page): Promise<string> {
|
||||
const res = await page.request.post('/api/test/mint-player-session');
|
||||
// v1.7.0 (ISS-116): optional username — mints a SECOND account with onboarding (the route has
|
||||
// supported the override since v1.1.0; this helper just never forwarded it). No arg = the suite's
|
||||
// usual local player, exactly as before.
|
||||
export async function mintPlayerSession(page: Page, username?: string): Promise<string> {
|
||||
const res = await page.request.post('/api/test/mint-player-session', { data: username ? { username } : {} });
|
||||
expect(res.status(), 'mint-player-session hook should be enabled in the CI stack (FLIGHT_TEST_HOOKS=1)').toBe(200);
|
||||
return ((await res.json()) as { sessionId: string }).sessionId;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import type { Browser, BrowserContext, Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { applySession, authenticate, mintPlayerSession } from './auth';
|
||||
|
||||
// v1.7.0 (ISS-116) — multiplayer e2e coverage: the N-player world clock (#135 rule × step 9/ISS-113)
|
||||
// and ownership isolation across two live sessions. Player A is the suite's usual minted local
|
||||
// player; player B is a second account minted through the same hook with a username override (the
|
||||
// route has supported it since v1.1.0 — e2e/auth.ts now forwards it).
|
||||
//
|
||||
// The CI DB volume persists across specs within one run, so this spec must leave no trace later
|
||||
// specs can't handle: player B's onboarding starter is unsellable (per-owner last-plane guard), so
|
||||
// an afterAll deletes it via the new /api/test/delete-plane hook. Between tests the plane stays —
|
||||
// re-minting is idempotent and test 2 needs a B-owned plane to exist.
|
||||
|
||||
const WORLD = 24; // fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||
const SCALE = 1000; // test rate for fast-forwarding legs
|
||||
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||
const STARTER = 'plane-starter'; // the seeded plane's id (lib/game/seed.ts)
|
||||
const LOCAL_USERNAME = 'kamilkrzywda'; // must match LOCAL_PLAYER.username in lib/db/localPlayer.ts
|
||||
const PLAYER_B = 'e2e-player-b'; // stable username — minting is idempotent across runs
|
||||
|
||||
interface PlaneState {
|
||||
id: string;
|
||||
status: string;
|
||||
locationIcao: string;
|
||||
maxFuel: number;
|
||||
queue: string[];
|
||||
ownerId: string;
|
||||
ownerName: string;
|
||||
}
|
||||
|
||||
interface PlayerSummary {
|
||||
id: string;
|
||||
username: string;
|
||||
cash: number;
|
||||
reputation: number;
|
||||
planeCount: number;
|
||||
}
|
||||
|
||||
interface StateBody {
|
||||
account: { cash: number; reputation: number };
|
||||
simState: { clockMs: number };
|
||||
planes: PlaneState[];
|
||||
players: PlayerSummary[];
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function getState(page: Page): Promise<StateBody> {
|
||||
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; retry briefly instead of throwing.
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
const res = await page.request.get('/api/state');
|
||||
if (res.status() === 200) return (await res.json()) as StateBody;
|
||||
if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
|
||||
async function setScale(page: Page, scale: number): Promise<void> {
|
||||
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
|
||||
expect(res.status()).toBe(200);
|
||||
const body = (await res.json()) as { timeScale: number };
|
||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
|
||||
}
|
||||
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): top a plane's tank back to full.
|
||||
async function setFuelFull(page: Page, planeId: string): Promise<void> {
|
||||
const p = (await getState(page)).planes.find((x) => x.id === planeId)!;
|
||||
expect(p.status).toBe('available'); // only tops up grounded planes — the fly call right after needs it
|
||||
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
|
||||
}
|
||||
|
||||
// Cancel every queued leg of a plane via the API (works while flying — the server checks no status).
|
||||
async function clearQueueViaApi(page: Page, planeId: string): Promise<void> {
|
||||
for (;;) {
|
||||
const q = (await getState(page)).planes.find((p) => p.id === planeId)?.queue ?? [];
|
||||
if (q.length === 0) return;
|
||||
expect((await page.request.post(`/api/planes/${planeId}/queue/cancel`, { data: { index: 0 } })).status()).toBe(200);
|
||||
}
|
||||
}
|
||||
|
||||
// Closest airport to the hub — a short, full-tank-flyable leg for the clock test.
|
||||
async function nearestToHub(page: Page): Promise<string> {
|
||||
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
||||
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
||||
};
|
||||
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
|
||||
return fc.features
|
||||
.filter((f) => f.properties.icao !== HUB)
|
||||
.map((f) => ({ icao: f.properties.icao, d: haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon) }))
|
||||
.sort((x, y) => x.d - y.d)[0].icao;
|
||||
}
|
||||
|
||||
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
|
||||
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||
}
|
||||
|
||||
// Mint the second account in its OWN context — sessions are per-context (headers + localStorage).
|
||||
async function mintPlayerB(browser: Browser): Promise<{ ctx: Awaited<ReturnType<Browser['newContext']>>; page: Page }> {
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
const sessionId = await mintPlayerSession(page, PLAYER_B);
|
||||
await applySession(page, sessionId);
|
||||
return { ctx, page };
|
||||
}
|
||||
|
||||
// Heal the starter to "grounded at the hub" — same pattern as healPlaneHome in two-planes.spec.ts.
|
||||
async function healStarterHome(page: Page): Promise<void> {
|
||||
await clearQueueViaApi(page, STARTER);
|
||||
let p = (await getState(page)).planes.find((x) => x.id === STARTER);
|
||||
if (!p) return; // cannot happen — the starter is local's last plane and unsellable
|
||||
await setScale(page, SCALE); // fast-forward any in-flight leg during cleanup
|
||||
if (p.status === 'flying') {
|
||||
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing.
|
||||
await expect
|
||||
.poll(async () => ((await getState(page)).planes.find((x) => x.id === STARTER)?.status ?? ''), {
|
||||
timeout: 240_000,
|
||||
message: 'waiting for an in-flight starter to land during cleanup',
|
||||
})
|
||||
.toBe('available');
|
||||
p = (await getState(page)).planes.find((x) => x.id === STARTER)!;
|
||||
}
|
||||
if (p.locationIcao !== HUB) {
|
||||
const flyHome = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
|
||||
expect(flyHome.status()).toBe(200);
|
||||
await expect
|
||||
.poll(async () => ((await getState(page)).planes.find((x) => x.id === STARTER)?.locationIcao ?? ''), {
|
||||
timeout: 240_000,
|
||||
message: 'waiting for the starter to land back at the hub during cleanup',
|
||||
})
|
||||
.toBe(HUB);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait until every plane in the world is grounded — an interrupted earlier spec (or a previous run's
|
||||
// retry) may have left the starter mid-flight, and both tests need a deterministic baseline.
|
||||
async function waitForGroundedFleet(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => ((await getState(page)).planes.every((p) => p.status === 'available') ? 'idle' : 'busy'), {
|
||||
timeout: 120_000,
|
||||
message: 'waiting for the fleet to be fully grounded',
|
||||
})
|
||||
.toBe('idle');
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: 'serial' }); // shared world + one minted second account — order matters
|
||||
|
||||
let bCtx: Awaited<ReturnType<Browser['newContext']>> | null = null;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await authenticate(page); // player A — the seeded local player that owns plane-starter
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
try {
|
||||
// Leave the world exactly as found: starter grounded at the hub, fixed world rate.
|
||||
await healStarterHome(page);
|
||||
await setScale(page, WORLD);
|
||||
} finally {
|
||||
if (bCtx) await bCtx.close();
|
||||
bCtx = null;
|
||||
}
|
||||
});
|
||||
|
||||
// File-level cleanup: player B's starter is unsellable, so it must be deleted — later specs'
|
||||
// single-fleet self-heal would otherwise hit a foreign plane. Runs even when tests failed (the hook
|
||||
// is idempotent), and needs no session: /api/test/* hooks are unauthenticated by design.
|
||||
test.afterAll(async ({ browser }) => {
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
expect((await page.request.post('/api/test/delete-plane', { data: { username: PLAYER_B } })).status()).toBe(200);
|
||||
// Verify through the same surface the game uses — mint a local session just for this read.
|
||||
const sessionId = await mintPlayerSession(page);
|
||||
await applySession(page, sessionId);
|
||||
const s = (await getState(page)) as StateBody;
|
||||
expect(s.planes.filter((p) => p.ownerName === PLAYER_B)).toEqual([]);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('N-player clock: one player flying keeps the world advancing; all grounded freezes it (ISS-113)', async ({ page, browser }) => {
|
||||
test.setTimeout(480_000); // self-heal + one short hop at scale 1000
|
||||
const b = await mintPlayerB(browser);
|
||||
bCtx = b.ctx;
|
||||
|
||||
await waitForGroundedFleet(page);
|
||||
await setScale(page, WORLD); // deterministic rate for the advance phase — sim.spec.ts pins the ×scale math
|
||||
|
||||
const dest = await nearestToHub(page);
|
||||
await clearQueueViaApi(page, STARTER); // no auto-chain on landing — A's plane must come to rest
|
||||
await setFuelFull(page, STARTER);
|
||||
expect((await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: dest } })).status()).toBe(200);
|
||||
|
||||
// Phase 1 — A flying while B stays grounded: the clock advances on every read. #135's freeze rule
|
||||
// is fleet-wide, not per-player: ONE owner airborne is enough to keep world time moving.
|
||||
let prev = (await getState(page)).simState.clockMs;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await sleep(500);
|
||||
const s = await getState(page); // tick-on-read — advances while any plane flies
|
||||
expect(s.planes.find((p) => p.id === STARTER)?.status).toBe('flying'); // A still airborne…
|
||||
expect(s.planes.filter((p) => p.ownerName === PLAYER_B).every((p) => p.status === 'available')).toBe(true); // …B grounded the whole time
|
||||
const now = s.simState.clockMs;
|
||||
expect(now, 'the clock must advance while any player is flying').toBeGreaterThan(prev);
|
||||
prev = now;
|
||||
}
|
||||
|
||||
// Phase 2 — land A: with EVERY player grounded, the clock freezes exactly (no idle creep).
|
||||
await setScale(page, SCALE);
|
||||
await expect
|
||||
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? ''), {
|
||||
timeout: 240_000,
|
||||
message: "waiting for A's plane to land",
|
||||
})
|
||||
.toBe('available');
|
||||
|
||||
const frozen1 = await getState(page);
|
||||
expect(frozen1.planes.every((p) => p.status === 'available'), 'every player must be grounded').toBe(true);
|
||||
await sleep(2_500); // ~2.5 s of real idleness — several background ticks
|
||||
const frozen2 = await getState(page);
|
||||
expect(frozen2.simState.clockMs, 'a fully grounded multi-player fleet must freeze the clock').toBe(frozen1.simState.clockMs);
|
||||
});
|
||||
|
||||
test('ownership isolation: 403 on foreign planes; per-session account rows; shared fleet with owner info', async ({ page, browser }) => {
|
||||
const b = await mintPlayerB(browser);
|
||||
bCtx = b.ctx;
|
||||
|
||||
// Deterministic baseline: a fully grounded fleet (no delivery can move cash mid-test).
|
||||
await waitForGroundedFleet(page);
|
||||
|
||||
const sA = await getState(page); // A's (local session's) view of the shared world
|
||||
const sB = await getState(b.page); // B's view — same world…
|
||||
expect(sB.planes.map((p) => p.id).sort()).toEqual(sA.planes.map((p) => p.id).sort()); // …both see every plane
|
||||
|
||||
// Owner info on every surface (v1.4.0): the starter belongs to local, B's starter to B.
|
||||
const starter = sA.planes.find((p) => p.id === STARTER)!;
|
||||
expect(starter.ownerId).toBe('local');
|
||||
expect(starter.ownerName).toBe(LOCAL_USERNAME);
|
||||
const bSummary = sB.players.find((pl) => pl.username === PLAYER_B);
|
||||
expect(bSummary, 'the players summary lists player B').toBeDefined();
|
||||
const bPlane = sA.planes.find((p) => p.ownerId === bSummary!.id)!;
|
||||
expect(bPlane.ownerName).toBe(PLAYER_B); // A sees WHOSE plane it is…
|
||||
expect(sB.planes.find((p) => p.id === bPlane.id)?.ownerName).toBe(PLAYER_B); // …and so does B
|
||||
|
||||
// A cannot act on B's plane — ownership wins over every state check (403, not the last-plane 409:
|
||||
// B's starter is B's only plane, so a missing ownership check would surface as 409 instead).
|
||||
const flyForeign = await page.request.post(`/api/planes/${bPlane.id}/fly`, { data: { destIcao: HUB } });
|
||||
expect(flyForeign.status()).toBe(403);
|
||||
expect((await flyForeign.json() as { error: string }).error).toMatch(/not your plane/i);
|
||||
const sellForeign = await page.request.post('/api/market/sell-plane', { data: { planeId: bPlane.id } });
|
||||
expect(sellForeign.status()).toBe(403);
|
||||
|
||||
// …and B cannot act on A's.
|
||||
const flyA = await b.page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
|
||||
expect(flyA.status()).toBe(403);
|
||||
|
||||
// Per-session account rows (v1.3.0): a grant to local moves ONLY A's row — the world is frozen,
|
||||
// so nothing else can touch either economy between reads.
|
||||
const cA = sA.account.cash;
|
||||
const cB = sB.account.cash;
|
||||
const grant = await page.request.post('/api/test/add-cash', { data: { amount: 1_000 } });
|
||||
expect(grant.status()).toBe(200);
|
||||
const granted = ((await grant.json()) as StateBody).account.cash;
|
||||
expect(Math.abs(granted - (cA + 1_000))).toBeLessThan(0.001); // A's row moved…
|
||||
const sBAfter = await getState(b.page);
|
||||
expect(sBAfter.account.cash, "B's account is untouched by a grant to local").toBe(cB);
|
||||
|
||||
// The players summary (v1.6.0) is visible in both sessions with per-owner plane counts.
|
||||
for (const s of [sA, sBAfter]) {
|
||||
expect(s.players.find((pl) => pl.username === LOCAL_USERNAME)?.planeCount).toBeGreaterThanOrEqual(1);
|
||||
expect(s.players.find((pl) => pl.username === PLAYER_B)?.planeCount).toBe(1);
|
||||
}
|
||||
});
|
||||
+12
-4
@@ -1,12 +1,12 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { eq, max } from 'drizzle-orm';
|
||||
import type { GameDb } from './client';
|
||||
import * as schema from './schema';
|
||||
|
||||
// v1.0.0 (ISS-105): single-player economy access — resolves the seeded local player row. As of
|
||||
// v1.3.0 (ISS-110) every per-player read/write goes through getPlayer/updatePlayerEconomy below;
|
||||
// these 'local' helpers remain ONLY for world-level reads with no acting session (the engine's
|
||||
// shared-pool spawn quality — pools are SHARED state, so the seeded row sets job quality) and
|
||||
// env-gated /api/test/* admin hooks. Since v1.4.0 (ISS-112) even the SSE broadcast is per-player.
|
||||
// these 'local' helpers remain ONLY for env-gated /api/test/* admin hooks that stage the SEEDED
|
||||
// account with no acting session. Since v1.4.0 (ISS-112) even the SSE broadcast is per-player, and
|
||||
// since v1.5.0 (ISS-111) shared-world job quality reads getSharedWorldReputation instead of a row.
|
||||
|
||||
export const LOCAL_PLAYER = { id: 'local', provider: 'local', username: 'kamilkrzywda' } as const;
|
||||
|
||||
@@ -39,3 +39,11 @@ export async function updatePlayerEconomy(
|
||||
): Promise<void> {
|
||||
await inst.db.update(schema.players).set(patch).where(eq(schema.players.id, id));
|
||||
}
|
||||
|
||||
// v1.5.0 (ISS-111), the #101 decision point resolved: shared-world job quality is the MAX
|
||||
// reputation across ALL player rows — spawned pool packages scale to the best-progressed player so
|
||||
// nobody is stuck on low-rep jobs. 0 when there are no players (repRate floors at ×1 either way).
|
||||
export async function getSharedWorldReputation(inst: GameDb): Promise<number> {
|
||||
const [row] = await inst.db.select({ m: max(schema.players.reputation) }).from(schema.players);
|
||||
return row?.m ?? 0;
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
+94
-1
@@ -11,7 +11,7 @@ import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||
import { weatherFor } from '@/lib/weather';
|
||||
import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, REPAIR_DURATION_MS, repRate, WEAR_PER_KM } from '@/lib/game/catalog';
|
||||
import { SPECIALS } from '@/lib/game/specials';
|
||||
import { JOB_LIFETIME_MS, POOL_BANDS, POOL_TARGET_PER_AIRPORT, REFILL_DELAY_MS, SMALL_REFILL_DELAY_MS, mulberry32, poolBandOf, STARTING_CLOCK_MS } from '@/lib/game/seed';
|
||||
import { JOB_BONUS_PER_REP, JOB_LIFETIME_MS, MAX_JOB_BONUS, POOL_BANDS, POOL_TARGET_PER_AIRPORT, REFILL_DELAY_MS, SMALL_REFILL_DELAY_MS, STARTING_CLOCK_MS, STARTING_REPUTATION, mulberry32, poolBandOf, rewardFor } from '@/lib/game/seed';
|
||||
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
|
||||
import {
|
||||
deliverPackages,
|
||||
@@ -742,6 +742,32 @@ describe('grounded fleet freezes the world clock (#135)', () => {
|
||||
expect(sim.clockMs).toBeGreaterThan(STARTING_CLOCK_MS); // the repair gate is what keeps time moving
|
||||
});
|
||||
|
||||
it('N-player fleet: one owner airborne keeps the clock running; all owners grounded freezes it (ISS-113)', async () => {
|
||||
const inst = await freshDb('nplayer-clock.db');
|
||||
// A second player with their own GROUNDED plane — the world now has two owners.
|
||||
await inst.db.insert(schema.players).values({ id: 'player-b', provider: 'test', username: 'b', cash: 1_000, reputation: STARTING_REPUTATION });
|
||||
await inst.db.insert(schema.planes).values({
|
||||
id: 'plane-b', ownerId: 'player-b', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||
locationIcao: 'EDDF', destIcao: null, lat: 50.03, lon: 8.57, departureSimTime: null,
|
||||
});
|
||||
|
||||
// A (local) takes off while B stays grounded → the world advances — one owner flying is enough…
|
||||
await makeWorldActive(inst);
|
||||
vi.setSystemTime(T0 + 1_000);
|
||||
const sim = await tickOnce(inst);
|
||||
expect(sim.clockMs).toBe(STARTING_CLOCK_MS + 1_000 * WORLD_TIME_SCALE); // …at the fixed world rate
|
||||
|
||||
// …and grounding A's plane (B was already grounded) freezes the whole world.
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ status: 'available', destIcao: null, departureSimTime: null })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
vi.setSystemTime(T0 + 2_000); // a full second of real idleness with every owner grounded
|
||||
const frozen = await tickOnce(inst);
|
||||
|
||||
expect(frozen.clockMs).toBe(STARTING_CLOCK_MS + 1_000 * WORLD_TIME_SCALE); // frozen — no advance…
|
||||
expect(frozen.lastRealTickMs).toBe(T0 + 2_000); // …but the real-time anchor keeps tracking
|
||||
});
|
||||
|
||||
it('a frozen tick still refills the pool so a grounded fleet never starves (#135 follow-up)', async () => {
|
||||
const inst = await freshDb('frozen-topup.db'); // seeded starter is on the ground → frozen
|
||||
// Drain the open market the way wall-clock expiry would (removal paths run while frozen).
|
||||
@@ -911,6 +937,73 @@ describe('spawnPoolPackages (v0.65.0 per-airport pools)', () => {
|
||||
expect(openRows.length).toBe(POOL_TARGET_PER_AIRPORT * 2); // both pools held through the full tick pipeline
|
||||
expect(openRows.every((p) => p.originIcao === 'EDDF' || p.originIcao === 'LEMD')).toBe(true);
|
||||
});
|
||||
|
||||
// v1.5.0 (ISS-111): shared-world spawn quality — MAX reputation across players. Independent
|
||||
// recomputation of a spawned package's reward at a given reputation, from the stored row with the
|
||||
// same formula generatePackage uses (rewardFor × rep bonus × special multiplier) — checks the rule
|
||||
// against the math, not against itself.
|
||||
async function expectedRewardAt(p: schema.PackageRow, reputation: number): Promise<number> {
|
||||
const fc = await loadAirportsGeoJson();
|
||||
const o = fc.features.find((f) => f.properties.icao === p.originIcao)!;
|
||||
const d = fc.features.find((f) => f.properties.icao === p.destIcao)!;
|
||||
const km = haversineKm(o.properties.lat, o.properties.lon, d.properties.lat, d.properties.lon);
|
||||
return Math.round(
|
||||
rewardFor(km, p.weightKg) * (1 + repRate(reputation, JOB_BONUS_PER_REP, MAX_JOB_BONUS)) * (p.special ? SPECIALS[p.special]?.rewardMultiplier ?? 1 : 1),
|
||||
);
|
||||
}
|
||||
|
||||
it('scales job quality by the MAX reputation across players, not the local row (v1.5.0, ISS-111)', async () => {
|
||||
const inst = await freshDb('pool-shared-rep.db');
|
||||
// Local sits at the starting rep (×1 bonus); player-b is far ahead — the shared-world rule must pick b's.
|
||||
await inst.db.insert(schema.players).values({ id: 'player-b', provider: 'test', username: 'b', cash: 1_000, reputation: 60 });
|
||||
for (const p of await inst.db.select().from(schema.packages)) {
|
||||
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
|
||||
}
|
||||
|
||||
expect(await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0)).toBe(POOL_TARGET_PER_AIRPORT);
|
||||
|
||||
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
|
||||
expect(openRows.length).toBe(POOL_TARGET_PER_AIRPORT);
|
||||
for (const p of openRows) {
|
||||
expect(p.reward).toBe(await expectedRewardAt(p, 60)); // every job scaled to the MAX…
|
||||
expect(p.reward).not.toBe(await expectedRewardAt(p, STARTING_REPUTATION)); // …not to local's starting rep
|
||||
}
|
||||
});
|
||||
|
||||
it('MAX picks the local row when local leads — symmetric to the player-b case (v1.5.0)', async () => {
|
||||
const inst = await freshDb('pool-shared-rep-local.db');
|
||||
// Local is far ahead of b's starting rep — a "prefer non-local" implementation would pick b and fail here.
|
||||
await updateLocalPlayerEconomy(inst, { reputation: 60 });
|
||||
await inst.db.insert(schema.players).values({ id: 'player-b', provider: 'test', username: 'b', cash: 1_000, reputation: STARTING_REPUTATION });
|
||||
for (const p of await inst.db.select().from(schema.packages)) {
|
||||
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
|
||||
}
|
||||
|
||||
expect(await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0)).toBe(POOL_TARGET_PER_AIRPORT);
|
||||
|
||||
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
|
||||
for (const p of openRows) {
|
||||
expect(p.reward).toBe(await expectedRewardAt(p, 60)); // every job scaled to the MAX…
|
||||
expect(p.reward).not.toBe(await expectedRewardAt(p, STARTING_REPUTATION)); // …not to b's starting rep
|
||||
}
|
||||
});
|
||||
|
||||
it("single player: spawn quality is that player's own reputation (v1.5.0 regression guard)", async () => {
|
||||
const inst = await freshDb('pool-single-rep.db');
|
||||
// Raise the only player's rep so the bonus is non-trivial, then respawn from zero with a fixed RNG —
|
||||
// with one row, MAX equals that row: identical to the pre-v1.5.0 singleton read.
|
||||
await updateLocalPlayerEconomy(inst, { reputation: 60 });
|
||||
for (const p of await inst.db.select().from(schema.packages)) {
|
||||
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
|
||||
}
|
||||
|
||||
expect(await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0)).toBe(POOL_TARGET_PER_AIRPORT);
|
||||
|
||||
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
|
||||
for (const p of openRows) {
|
||||
expect(p.reward).toBe(await expectedRewardAt(p, 60));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Local helper mirroring lib/sim/movement.ts (kept private there on purpose — the
|
||||
|
||||
+6
-6
@@ -1,7 +1,7 @@
|
||||
import { randomInt, randomUUID } from 'node:crypto';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { getGameDb, persist, type GameDb } from '@/lib/db/client';
|
||||
import { getLocalPlayer, getPlayer, updatePlayerEconomy } from '@/lib/db/localPlayer';
|
||||
import { getSharedWorldReputation, getPlayer, updatePlayerEconomy } from '@/lib/db/localPlayer';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||
@@ -385,10 +385,10 @@ export async function spawnPoolPackages(
|
||||
}));
|
||||
const rand = rng ?? mulberry32(randomInt(0, 2 ** 31));
|
||||
// v0.38.0 (ISS-072): spawned jobs carry a reputation-based job quality — read once per spawn batch.
|
||||
// ponytail: stays on the seeded 'local' row — pools are SHARED world state (packages have no
|
||||
// owner), so there is no single owning player whose rep should set job quality; per-player job
|
||||
// quality is a later multiplayer step.
|
||||
const player = await getLocalPlayer(inst);
|
||||
// v1.5.0 (ISS-111), the #101 decision point: pools are SHARED world state, so quality follows the
|
||||
// agreed shared-world rule — MAX reputation across all players — and scales to the best-progressed
|
||||
// player instead of the seeded local row.
|
||||
const reputation = await getSharedWorldReputation(inst);
|
||||
|
||||
let n = 0;
|
||||
for (const originIcao of pool) {
|
||||
@@ -418,7 +418,7 @@ export async function spawnPoolPackages(
|
||||
clockMs,
|
||||
id: randomUUID(),
|
||||
createdRealMs: real,
|
||||
reputation: player.reputation,
|
||||
reputation,
|
||||
}),
|
||||
);
|
||||
openByBand[bandIdx]++;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flight-ops",
|
||||
"version": "1.4.0",
|
||||
"version": "1.7.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
|
||||
Reference in New Issue
Block a user