v1.4.0: per-player snapshot + shared market view with owner info (ISS-112)
CI / build + unit + e2e (pull_request) Successful in 14m29s
CI / build + unit + e2e (pull_request) Successful in 14m29s
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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`.
|
||||||
|
- **UI.** Left pane fleet rows show an owner badge (username) next to the model name — every plane in the world is visible and labeled. Right pane open-package rows show a carrier-name badge when `carrierName` is set (open packages are unassigned today, so it renders once assignment lands; the join + render path exist now). One small `.owner-badge` style mirrors `.special-badge`.
|
||||||
|
- **OpenAPI.** `PlaneState` gains required `ownerId`/`ownerName`; `PackageState` gains required nullable `carrierName` — documented in `lib/openapi.ts`, path count unchanged at eighteen.
|
||||||
|
- **Tests.** Unit: `lib/sse.test.ts` reworked for the keyed registry (per-player isolation, empty-group drop, throwing subscriber, idempotent unsubscribe); new `/api/state` route tests assert all planes carry owner info including a foreign player's plane, and that carrierName joins through planeId → owner_id → username while unassigned packages stay null; new `/api/events` test opens TWO streams (local + a second minted player) and asserts each tick push carries the stream's OWN account row — plus connect-frame parity assertions (owner fields present, same projection as GET). Fixture helpers in `state.test.ts`, `deriveEvents.test.ts`, `right-pane.test.ts`, `mapData.test.ts` gain the new fields. No e2e spec changes: all pane/API assertions are additive-safe (`toContainText`, per-key `toHaveProperty`), and CI's existing suite covers the UI paths.
|
||||||
|
|
||||||
## v1.3.0 — 2026-09-26 17:05
|
## v1.3.0 — 2026-09-26 17:05
|
||||||
- **Multiplayer step 6/12 (ISS-110): per-player money & reputation in the engine + snapshot.** The last singleton economy accesses are converted to per-player rows. `deliverPackages` (`lib/sim/engine.ts`) now pays cash + reputation to the DELIVERED PLANE'S OWNER row (`plane.owner_id`, fresh read per award so multiple deliveries in one tick all count), and the ISS-082 auto-chain top-up debits the same owner's row with their own rep discount — a plane owned by player B earning or refueling never touches any other account. `getStateSnapshot(playerId)` sources the snapshot's `account` field from the ACTING SESSION player's row; the field name/shape (`{cash, reputation}`) is unchanged and OpenAPI stays untouched. Session-scoped callers thread it: `/api/state`, every action response (`performAction`) and the SSE connect frame all pass `auth.player.id`.
|
- **Multiplayer step 6/12 (ISS-110): per-player money & reputation in the engine + snapshot.** The last singleton economy accesses are converted to per-player rows. `deliverPackages` (`lib/sim/engine.ts`) now pays cash + reputation to the DELIVERED PLANE'S OWNER row (`plane.owner_id`, fresh read per award so multiple deliveries in one tick all count), and the ISS-082 auto-chain top-up debits the same owner's row with their own rep discount — a plane owned by player B earning or refueling never touches any other account. `getStateSnapshot(playerId)` sources the snapshot's `account` field from the ACTING SESSION player's row; the field name/shape (`{cash, reputation}`) is unchanged and OpenAPI stays untouched. Session-scoped callers thread it: `/api/state`, every action response (`performAction`) and the SSE connect frame all pass `auth.player.id`.
|
||||||
- **Sites that stay on 'local' (deliberate).** The shared SSE broadcast has no acting session — it passes `LOCAL_PLAYER.id` until per-player frames land (#112). The engine's pool-spawn job-quality read 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 quality; a ponytail comment names #112 as the decision point. The env-gated `/api/test/*` economy hooks (add-cash, set-cash) remain admin-style tools with no session — they stage the SEEDED local account, which is exactly the player the e2e suite plays via `mint-player-session`'s default; create-package's job-quality read matches the engine spawn.
|
- **Sites that stay on 'local' (deliberate).** The shared SSE broadcast has no acting session — it passes `LOCAL_PLAYER.id` until per-player frames land (#112). The engine's pool-spawn job-quality read 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 quality; a ponytail comment names #112 as the decision point. The env-gated `/api/test/*` economy hooks (add-cash, set-cash) remain admin-style tools with no session — they stage the SEEDED local account, which is exactly the player the e2e suite plays via `mint-player-session`'s default; create-package's job-quality read matches the engine spawn.
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import fs from 'node:fs/promises';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
import { createSession } from '@/lib/auth';
|
import { createSession, hashPassword } from '@/lib/auth';
|
||||||
|
import * as schema from '@/lib/db/schema';
|
||||||
import { getGameDb } from '@/lib/db/client';
|
import { getGameDb } from '@/lib/db/client';
|
||||||
|
import { getPlayer } from '@/lib/db/localPlayer';
|
||||||
|
import { tickNow } from '@/lib/sim/engine';
|
||||||
import { GET } from './route';
|
import { GET } from './route';
|
||||||
|
|
||||||
let dir: string;
|
let dir: string;
|
||||||
@@ -49,8 +52,63 @@ describe('GET /api/events', () => {
|
|||||||
expect(text.startsWith('data: ')).toBe(true);
|
expect(text.startsWith('data: ')).toBe(true);
|
||||||
const snap = JSON.parse(text.slice('data: '.length)) as Record<string, unknown>;
|
const snap = JSON.parse(text.slice('data: '.length)) as Record<string, unknown>;
|
||||||
expect(typeof (snap.simState as { clockMs: number }).clockMs).toBe('number');
|
expect(typeof (snap.simState as { clockMs: number }).clockMs).toBe('number');
|
||||||
|
|
||||||
|
// v1.4.0 (ISS-112): the connect frame comes from the SAME shared projection as GET /api/state —
|
||||||
|
// planes carry owner info and packages carry carrierName, so pushed frames can never drift.
|
||||||
|
const planes = snap.planes as Array<Record<string, unknown>>;
|
||||||
|
expect(planes.length).toBeGreaterThanOrEqual(1);
|
||||||
|
for (const key of ['ownerId', 'ownerName']) {
|
||||||
|
expect(planes[0], `plane.${key}`).toHaveProperty(key);
|
||||||
|
}
|
||||||
|
const pkgs = snap.packages as Array<Record<string, unknown>>;
|
||||||
|
for (const p of pkgs) expect(p, 'package.carrierName').toHaveProperty('carrierName');
|
||||||
} finally {
|
} finally {
|
||||||
await reader.cancel(); // → stream cancel → unsubscribe from the registry
|
await reader.cancel(); // → stream cancel → unsubscribe from the registry
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('pushes PER-PLAYER frames on tick — each stream gets its own account row (v1.4.0, ISS-112)', async () => {
|
||||||
|
const inst = await getGameDb();
|
||||||
|
// A second player with a distinct economy + their own session (rows minted like /api/register).
|
||||||
|
const idB = 'test-player-events-b';
|
||||||
|
await inst.db.insert(schema.users).values({
|
||||||
|
id: idB, username: 'e2e-events-b', passwordHash: hashPassword('unused'), admin: false, createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
await inst.db.insert(schema.players).values({
|
||||||
|
id: idB, provider: 'local', username: 'e2e-events-b', cash: 77_777.77, reputation: 3,
|
||||||
|
});
|
||||||
|
const sidB = await createSession(inst, idB);
|
||||||
|
const localCash = (await getPlayer(inst, 'local')).cash;
|
||||||
|
|
||||||
|
// Two live streams: the seeded local player and player B — both subscribed under their own ids.
|
||||||
|
const resA = await GET(new Request(`http://localhost/api/events?session=${sessionId}`));
|
||||||
|
const readerA = (resA.body as ReadableStream<Uint8Array>).getReader();
|
||||||
|
const resB = await GET(new Request(`http://localhost/api/events?session=${sidB}`));
|
||||||
|
const readerB = (resB.body as ReadableStream<Uint8Array>).getReader();
|
||||||
|
|
||||||
|
// Reads the next `data:` event off a stream and parses its snapshot.
|
||||||
|
const readFrame = async (reader: ReadableStreamDefaultReader<Uint8Array>) => {
|
||||||
|
const { value } = await reader.read();
|
||||||
|
return JSON.parse(new TextDecoder().decode(value).slice('data: '.length)) as Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Drain both connect frames (each already carries its own account row)…
|
||||||
|
const connectA = await readFrame(readerA);
|
||||||
|
const connectB = await readFrame(readerB);
|
||||||
|
expect((connectA.account as { cash: number }).cash).toBeCloseTo(localCash, 9);
|
||||||
|
expect((connectB.account as { cash: number }).cash).toBeCloseTo(77_777.77, 9);
|
||||||
|
|
||||||
|
// …then one tick — the broadcast projects per connected player id and fans each frame out to its group only.
|
||||||
|
await tickNow();
|
||||||
|
const pushA = await readFrame(readerA);
|
||||||
|
const pushB = await readFrame(readerB);
|
||||||
|
|
||||||
|
expect((pushA.account as { cash: number }).cash).toBeCloseTo(localCash, 9); // A gets LOCAL's row…
|
||||||
|
expect((pushB.account as { cash: number }).cash).toBeCloseTo(77_777.77, 9); // …B gets ITS OWN — never crossed over
|
||||||
|
} finally {
|
||||||
|
await readerA.cancel(); // → stream cancel → unsubscribe from the registry
|
||||||
|
await readerB.cancel();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import { subscribe } from '@/lib/sse';
|
|||||||
// EventSource cannot set headers, so the session comes from X-Flight-Session OR ?session=
|
// EventSource cannot set headers, so the session comes from X-Flight-Session OR ?session=
|
||||||
// (requireAuthSse). ponytail: validated at CONNECT only — a session deleted/expired mid-stream keeps
|
// (requireAuthSse). ponytail: validated at CONNECT only — a session deleted/expired mid-stream keeps
|
||||||
// receiving pushes until it reconnects (private infra, 7-day sessions; per-push re-validation would
|
// receiving pushes until it reconnects (private infra, 7-day sessions; per-push re-validation would
|
||||||
// be one indexed lookup per tick if that ever matters).
|
// be one indexed lookup per tick if that ever matters). v1.4.0 (ISS-112): frames are PER PLAYER — the
|
||||||
|
// route subscribes under THIS session's player id and the engine projects one snapshot per connected
|
||||||
|
// player, pushing each frame only to its group; connect + pushed frames share one projection
|
||||||
|
// (getStateSnapshot + sseFrame), so they can never drift.
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const HEARTBEAT_MS = 15_000;
|
const HEARTBEAT_MS = 15_000;
|
||||||
@@ -30,12 +33,12 @@ export async function GET(req: Request) {
|
|||||||
start(controller) {
|
start(controller) {
|
||||||
// Snapshot FIRST, then subscribe — a broadcast can never land before the initial event, so
|
// Snapshot FIRST, then subscribe — a broadcast can never land before the initial event, so
|
||||||
// events are strictly ordered by sim clock. A tick in the gap just means the client waits ≤1 s
|
// events are strictly ordered by sim clock. A tick in the gap just means the client waits ≤1 s
|
||||||
// for the next push instead of seeing an out-of-order frame. v1.3.0 (ISS-110): the connect
|
// for the next push instead of seeing an out-of-order frame. v1.4.0 (ISS-112): connect frame and
|
||||||
// frame carries THIS session's account row; later pushes come from the shared broadcast
|
// every later push carry THIS session's player projection — subscribe keys on auth.player.id so
|
||||||
// (local player) until per-player frames land (#112).
|
// the tick broadcast only sends this client frames projected for its own account row.
|
||||||
getStateSnapshot(auth.player.id)
|
getStateSnapshot(auth.player.id)
|
||||||
.then((snapshot) => {
|
.then((snapshot) => {
|
||||||
unsubscribe = subscribe((text) => {
|
unsubscribe = subscribe(auth.player.id, (text) => {
|
||||||
try {
|
try {
|
||||||
// broadcast carries the bare snapshot JSON — wrap it in an SSE frame here (the route owns
|
// broadcast carries the bare snapshot JSON — wrap it in an SSE frame here (the route owns
|
||||||
// wire framing, matching the initial snapshot + heartbeat above; lib/sse stays generic).
|
// wire framing, matching the initial snapshot + heartbeat above; lib/sse stays generic).
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||||
import { haversineKm } from '@/lib/geo';
|
import { haversineKm } from '@/lib/geo';
|
||||||
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
|
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
|
||||||
@@ -58,8 +59,8 @@ describe('GET /api/state', () => {
|
|||||||
|
|
||||||
const planes = body.planes as Array<Record<string, unknown>>;
|
const planes = body.planes as Array<Record<string, unknown>>;
|
||||||
expect(planes.length).toBeGreaterThanOrEqual(1);
|
expect(planes.length).toBeGreaterThanOrEqual(1);
|
||||||
for (const key of ['id', 'model', 'status', 'fuel', 'maxFuel', 'locationIcao', 'locationName', 'destIcao', 'destName', 'lat', 'lon']) {
|
for (const key of ['id', 'model', 'status', 'fuel', 'maxFuel', 'locationIcao', 'locationName', 'destIcao', 'destName', 'lat', 'lon', 'ownerId', 'ownerName']) {
|
||||||
expect(planes[0], `plane.${key}`).toHaveProperty(key);
|
expect(planes[0], `plane.${key}`).toHaveProperty(key); // ownerId/ownerName: v1.4.0 (ISS-112)
|
||||||
}
|
}
|
||||||
expect(typeof planes[0].locationName).toBe('string');
|
expect(typeof planes[0].locationName).toBe('string');
|
||||||
// The seeded starter is grounded — no flight destination yet.
|
// The seeded starter is grounded — no flight destination yet.
|
||||||
@@ -68,8 +69,8 @@ describe('GET /api/state', () => {
|
|||||||
|
|
||||||
const packages = body.packages as Array<Record<string, unknown>>;
|
const packages = body.packages as Array<Record<string, unknown>>;
|
||||||
expect(packages.length).toBeGreaterThan(0);
|
expect(packages.length).toBeGreaterThan(0);
|
||||||
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'weightKg', 'distanceKm', 'deadlineSimTime', 'status']) {
|
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'weightKg', 'distanceKm', 'deadlineSimTime', 'status', 'carrierName']) {
|
||||||
expect(packages[0], `package.${key}`).toHaveProperty(key);
|
expect(packages[0], `package.${key}`).toHaveProperty(key); // carrierName: v1.4.0 (ISS-112)
|
||||||
}
|
}
|
||||||
|
|
||||||
// v0.48.0 (ISS-095): distanceKm is computed server-side — it must equal the haversine of the
|
// v0.48.0 (ISS-095): distanceKm is computed server-side — it must equal the haversine of the
|
||||||
@@ -133,4 +134,52 @@ describe('GET /api/state', () => {
|
|||||||
expect(body.account.cash).toBeCloseTo(12_345.67, 9); // the session player's row…
|
expect(body.account.cash).toBeCloseTo(12_345.67, 9); // the session player's row…
|
||||||
expect(body.account.reputation).toBeCloseTo(42.5, 9); // …not the seeded local account
|
expect(body.account.reputation).toBeCloseTo(42.5, 9); // …not the seeded local account
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('carries ALL planes with owner info — other players\' planes included (v1.4.0, ISS-112)', async () => {
|
||||||
|
const inst = await getGameDb();
|
||||||
|
// A second player with a plane of their own — rows minted like /api/register does.
|
||||||
|
const id = 'test-player-c';
|
||||||
|
await inst.db.insert(schema.users).values({
|
||||||
|
id, username: 'e2e-owner-c', passwordHash: hashPassword('unused'), admin: false, createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
await inst.db.insert(schema.players).values({
|
||||||
|
id, provider: 'local', username: 'e2e-owner-c', cash: 1000, reputation: 1,
|
||||||
|
});
|
||||||
|
const type = PLANE_TYPES.atr72;
|
||||||
|
await inst.db.insert(schema.planes).values({
|
||||||
|
id: 'plane-test-c', ownerId: id, model: 'atr72', status: 'available',
|
||||||
|
fuel: type.maxFuel, maxFuel: type.maxFuel, locationIcao: 'EDDF', lat: 50.03, lon: 8.56,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await GET(req()); // the LOCAL session — still sees the whole world's fleet
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { planes: Array<{ id: string; ownerId: string; ownerName: string }> };
|
||||||
|
const starter = body.planes.find((p) => p.id === 'plane-starter');
|
||||||
|
expect(starter?.ownerId).toBe('local'); // seeded fleet backfilled to the local player…
|
||||||
|
expect(starter?.ownerName).toBe('kamilkrzywda'); // …and every row names its owner by username.
|
||||||
|
const foreign = body.planes.find((p) => p.id === 'plane-test-c');
|
||||||
|
expect(foreign?.ownerId).toBe(id);
|
||||||
|
expect(foreign?.ownerName).toBe('e2e-owner-c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('joins carrierName via package → planeId → planes.owner_id → players.username (v1.4.0, ISS-112)', async () => {
|
||||||
|
const inst = await getGameDb();
|
||||||
|
// Reuse the foreign player/plane from the previous test (same throwaway DB within this file).
|
||||||
|
const before = ((await GET(req())).json()) as Promise<{ packages: Array<Record<string, unknown>> }>;
|
||||||
|
const snapBefore = await before;
|
||||||
|
const openPkg = snapBefore.packages.find((p) => p.status === 'open')!;
|
||||||
|
|
||||||
|
// Assign it to the foreign player's plane directly (loadPackage does exactly this).
|
||||||
|
await inst.db.update(schema.packages)
|
||||||
|
.set({ status: 'loaded', planeId: 'plane-test-c' })
|
||||||
|
.where(eq(schema.packages.id, String(openPkg.id)));
|
||||||
|
|
||||||
|
const snap = ((await GET(req())).json()) as Promise<{ packages: Array<Record<string, unknown>> }>;
|
||||||
|
const after = await snap;
|
||||||
|
const assigned = after.packages.find((p) => p.id === openPkg.id)!;
|
||||||
|
expect(assigned.carrierName).toBe('e2e-owner-c'); // the carrying plane's owner…
|
||||||
|
for (const pkg of after.packages) { // …and every unassigned package stays null.
|
||||||
|
if (pkg.planeId == null) expect(pkg.carrierName, `package ${String(pkg.id)}`).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { requireAuth } from '@/lib/auth';
|
|||||||
import { tickNow } from '@/lib/sim/engine';
|
import { tickNow } from '@/lib/sim/engine';
|
||||||
import { getStateSnapshot } from '@/lib/game/state';
|
import { getStateSnapshot } from '@/lib/game/state';
|
||||||
|
|
||||||
// v1.1.0 (ISS-107): guarded — 401 without a live X-Flight-Session. The snapshot shape is
|
// v1.1.0 (ISS-107): guarded — 401 without a live X-Flight-Session.
|
||||||
// unchanged; per-player snapshots are a later step (#112).
|
|
||||||
// v1.3.0 (ISS-110): `account` carries the SESSION player's economy row.
|
// v1.3.0 (ISS-110): `account` carries the SESSION player's economy row.
|
||||||
|
// v1.4.0 (ISS-112): full shared-world snapshot — ALL planes with owner info, packages with
|
||||||
|
// carrierName; same projection as the SSE frames, so poll and push can never drift.
|
||||||
export async function GET(req: Request) {
|
export async function GET(req: Request) {
|
||||||
const auth = await requireAuth(req);
|
const auth = await requireAuth(req);
|
||||||
if (!auth.ok) return auth.response;
|
if (!auth.ok) return auth.response;
|
||||||
|
|||||||
@@ -244,6 +244,15 @@ body {
|
|||||||
color: #bae6fd;
|
color: #bae6fd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* v1.4.0 (ISS-112): owner / carrier badge — whose plane or cargo this is in the shared world. */
|
||||||
|
.owner-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(74, 222, 128, 0.15);
|
||||||
|
color: #bbf7d0;
|
||||||
|
}
|
||||||
|
|
||||||
.fuel-bar {
|
.fuel-bar {
|
||||||
height: 4px;
|
height: 4px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
|
|||||||
@@ -157,6 +157,10 @@ export default function LeftPane({ state, loading, error, applySnapshot, onHover
|
|||||||
>
|
>
|
||||||
<div className="plane-main">
|
<div className="plane-main">
|
||||||
<span className="plane-model">{PLANE_TYPES[p.model]?.name ?? p.model}</span>
|
<span className="plane-model">{PLANE_TYPES[p.model]?.name ?? p.model}</span>
|
||||||
|
{/* v1.4.0 (ISS-112): the snapshot carries ALL planes — badge names the owner. */}
|
||||||
|
<span data-testid={`plane-owner-${p.id}`} className="owner-badge" title={`Owned by ${p.ownerName}`}>
|
||||||
|
{p.ownerName}
|
||||||
|
</span>
|
||||||
{p.status === 'flying' ? (
|
{p.status === 'flying' ? (
|
||||||
<>
|
<>
|
||||||
<span className="plane-status status-flying">in flight</span>
|
<span className="plane-status status-flying">in flight</span>
|
||||||
|
|||||||
@@ -209,6 +209,12 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
|||||||
{formatSpecialLabel(pkg.special)}
|
{formatSpecialLabel(pkg.special)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{/* v1.4.0 (ISS-112): carrier name — who is carrying this package; rendered only when set. */}
|
||||||
|
{pkg.carrierName && (
|
||||||
|
<span data-testid={`open-package-carrier-${pkg.id}`} className="owner-badge">
|
||||||
|
{pkg.carrierName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{eligible.length > 0 ? (
|
{eligible.length > 0 ? (
|
||||||
<LoadControls
|
<LoadControls
|
||||||
pkg={pkg}
|
pkg={pkg}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ function plane(over: Partial<PlaneState> & Pick<PlaneState, 'id'>): PlaneState {
|
|||||||
health: 100, // v0.39.0 (ISS-069) — full-health fixture default
|
health: 100, // v0.39.0 (ISS-069) — full-health fixture default
|
||||||
repairUntilSimTime: null,
|
repairUntilSimTime: null,
|
||||||
queue: [],
|
queue: [],
|
||||||
|
ownerId: 'local', // v1.4.0 (ISS-112) — owner info on every plane row
|
||||||
|
ownerName: 'kamilkrzywda',
|
||||||
...over,
|
...over,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -48,6 +50,7 @@ function pkg(over: Partial<PackageState> & Pick<PackageState, 'id'>): PackageSta
|
|||||||
planeId: null,
|
planeId: null,
|
||||||
createdSimTime: 0,
|
createdSimTime: 0,
|
||||||
expiresRealMs: null, // v0.65.0 — wall-clock expiry; open fixtures override as needed
|
expiresRealMs: null, // v0.65.0 — wall-clock expiry; open fixtures override as needed
|
||||||
|
carrierName: null, // v1.4.0 (ISS-112) — unassigned by default in fixtures
|
||||||
...over,
|
...over,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import * as schema from './schema';
|
|||||||
// v1.0.0 (ISS-105): single-player economy access — resolves the seeded local player row. As of
|
// 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;
|
// 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
|
// these 'local' helpers remain ONLY for world-level reads with no acting session (the engine's
|
||||||
// shared-pool spawn quality, the SSE broadcast account) and env-gated /api/test/* admin hooks.
|
// 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.
|
||||||
|
|
||||||
export const LOCAL_PLAYER = { id: 'local', provider: 'local', username: 'kamilkrzywda' } as const;
|
export const LOCAL_PLAYER = { id: 'local', provider: 'local', username: 'kamilkrzywda' } as const;
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ const basePlane = (id: string): PlaneState => ({
|
|||||||
health: 100, // v0.39.0 (ISS-069) — full-health fixture default
|
health: 100, // v0.39.0 (ISS-069) — full-health fixture default
|
||||||
repairUntilSimTime: null,
|
repairUntilSimTime: null,
|
||||||
queue: [],
|
queue: [],
|
||||||
|
ownerId: 'local', // v1.4.0 (ISS-112) — owner info on every plane row
|
||||||
|
ownerName: 'kamilkrzywda',
|
||||||
});
|
});
|
||||||
|
|
||||||
const basePackage = (id: string): PackageState => ({
|
const basePackage = (id: string): PackageState => ({
|
||||||
@@ -44,6 +46,7 @@ const basePackage = (id: string): PackageState => ({
|
|||||||
status: 'open',
|
status: 'open',
|
||||||
planeId: null,
|
planeId: null,
|
||||||
createdSimTime: 0,
|
createdSimTime: 0,
|
||||||
|
carrierName: null, // v1.4.0 (ISS-112) — unassigned by default in fixtures
|
||||||
});
|
});
|
||||||
|
|
||||||
const snapshot = (over: Partial<StateSnapshot> = {}): StateSnapshot => ({
|
const snapshot = (over: Partial<StateSnapshot> = {}): StateSnapshot => ({
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const pkg = (status: PackageState['status'], id: string): PackageState => ({
|
|||||||
status,
|
status,
|
||||||
planeId: null,
|
planeId: null,
|
||||||
createdSimTime: 0,
|
createdSimTime: 0,
|
||||||
|
carrierName: null, // v1.4.0 (ISS-112) — unassigned by default in fixtures
|
||||||
});
|
});
|
||||||
|
|
||||||
const snapshot = (packages: PackageState[]): StateSnapshot => ({
|
const snapshot = (packages: PackageState[]): StateSnapshot => ({
|
||||||
|
|||||||
+28
-3
@@ -37,6 +37,10 @@ export interface PlaneState {
|
|||||||
health: number;
|
health: number;
|
||||||
// Sim-clock epoch ms when an active repair finishes (v0.39.0, ISS-069); null when not in repair.
|
// Sim-clock epoch ms when an active repair finishes (v0.39.0, ISS-069); null when not in repair.
|
||||||
repairUntilSimTime: number | null;
|
repairUntilSimTime: number | null;
|
||||||
|
// v1.4.0 (ISS-112): owner info — the snapshot carries ALL planes (shared world), so every row
|
||||||
|
// names its owner (players.id + username) and the panes can tell whose plane is whose.
|
||||||
|
ownerId: string;
|
||||||
|
ownerName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the planes.queue JSON column into an ICAO list (v0.25.0). Guards against
|
// Parse the planes.queue JSON column into an ICAO list (v0.25.0). Guards against
|
||||||
@@ -88,6 +92,9 @@ export interface PackageState {
|
|||||||
// Wall-clock expiry of an OPEN job (v0.65.0) — createdRealMs + JOB_LIFETIME_MS, shown as a live
|
// Wall-clock expiry of an OPEN job (v0.65.0) — createdRealMs + JOB_LIFETIME_MS, shown as a live
|
||||||
// countdown in the right pane; null for every non-open status (loaded cargo still uses deadlineSimTime).
|
// countdown in the right pane; null for every non-open status (loaded cargo still uses deadlineSimTime).
|
||||||
expiresRealMs: number | null;
|
expiresRealMs: number | null;
|
||||||
|
// v1.4.0 (ISS-112): carrier name — players.username of planes.owner_id via planeId, so an
|
||||||
|
// assigned package shows WHO is carrying it in the shared world; null when unassigned.
|
||||||
|
carrierName: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StateSnapshot {
|
export interface StateSnapshot {
|
||||||
@@ -115,19 +122,25 @@ export function sseFrame(snapshot: StateSnapshot): StateSnapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// v1.3.0 (ISS-110): the snapshot's `account` field is the ACTING SESSION player's row — same
|
// v1.3.0 (ISS-110): the snapshot's `account` field is the ACTING SESSION player's row — same
|
||||||
// shape as before ({cash, reputation}), sourced per-player. Callers without a session context
|
// shape as before ({cash, reputation}), sourced per-player. v1.4.0 (ISS-112): this IS the shared
|
||||||
// (the shared SSE broadcast) pass LOCAL_PLAYER.id until per-player frames land (#112).
|
// projection for every surface that serves state — GET /api/state, action responses, the SSE
|
||||||
|
// connect frame and each tick's broadcast all call it with a player id (the broadcast projects
|
||||||
|
// once PER CONNECTED SESSION), so no two surfaces can drift. Planes are ALL planes in the world,
|
||||||
|
// each carrying owner info; packages carry carrierName when assigned to a plane.
|
||||||
export async function getStateSnapshot(playerId: string): Promise<StateSnapshot> {
|
export async function getStateSnapshot(playerId: string): Promise<StateSnapshot> {
|
||||||
const inst = await getGameDb();
|
const inst = await getGameDb();
|
||||||
const { db } = inst;
|
const { db } = inst;
|
||||||
// NOTE: use plain select(), not the relational db.query.* API — in drizzle-orm
|
// NOTE: use plain select(), not the relational db.query.* API — in drizzle-orm
|
||||||
// 0.45.x the sql-js driver drops the result mapper for relational queries and
|
// 0.45.x the sql-js driver drops the result mapper for relational queries and
|
||||||
// returns raw rows keyed by SQL column names (snake_case, no type decoding).
|
// returns raw rows keyed by SQL column names (snake_case, no type decoding).
|
||||||
const [player, simRows, planeRows, packageRows] = await Promise.all([
|
const [player, simRows, planeRows, packageRows, playerRows] = await Promise.all([
|
||||||
getPlayer(inst, playerId),
|
getPlayer(inst, playerId),
|
||||||
db.select().from(schema.simState),
|
db.select().from(schema.simState),
|
||||||
db.select().from(schema.planes),
|
db.select().from(schema.planes),
|
||||||
db.select().from(schema.packages),
|
db.select().from(schema.packages),
|
||||||
|
// v1.4.0 (ISS-112): one players read serves both owner joins — plane rows via ownerId and
|
||||||
|
// package carrier names via planeId → planes.owner_id → players.username.
|
||||||
|
db.select().from(schema.players),
|
||||||
]);
|
]);
|
||||||
const simRow = simRows[0];
|
const simRow = simRows[0];
|
||||||
if (!simRow) throw new Error('game state missing sim_state row');
|
if (!simRow) throw new Error('game state missing sim_state row');
|
||||||
@@ -148,6 +161,11 @@ export async function getStateSnapshot(playerId: string): Promise<StateSnapshot>
|
|||||||
// body entirely, so clients never see jobs they could not dispatch.
|
// body entirely, so clients never see jobs they could not dispatch.
|
||||||
const pool = poolAirports(planeRows);
|
const pool = poolAirports(planeRows);
|
||||||
|
|
||||||
|
// v1.4.0 (ISS-112): owner joins — every plane row names its owner; a package's carrier is the
|
||||||
|
// username of ITS PLANE'S owner (planeId → planes.owner_id → players.username), null when unassigned.
|
||||||
|
const usernameByPlayerId = new Map(playerRows.map((pl) => [pl.id, pl.username]));
|
||||||
|
const planeById = new Map(planeRows.map((p) => [p.id, p]));
|
||||||
|
|
||||||
// Analytic movement (v0.7.0): a flying plane's stored lat/lon/fuel are its
|
// Analytic movement (v0.7.0): a flying plane's stored lat/lon/fuel are its
|
||||||
// departure values — live position and fuel are pure functions of the clock.
|
// departure values — live position and fuel are pure functions of the clock.
|
||||||
const livePlane = (p: schema.PlaneRow) => {
|
const livePlane = (p: schema.PlaneRow) => {
|
||||||
@@ -196,6 +214,8 @@ export async function getStateSnapshot(playerId: string): Promise<StateSnapshot>
|
|||||||
queue: parseIcaoQueue(p.queue),
|
queue: parseIcaoQueue(p.queue),
|
||||||
health: p.health, // v0.39.0 (ISS-069) — stored value; wear accumulates at landing only
|
health: p.health, // v0.39.0 (ISS-069) — stored value; wear accumulates at landing only
|
||||||
repairUntilSimTime: p.repairUntilSimTime,
|
repairUntilSimTime: p.repairUntilSimTime,
|
||||||
|
ownerId: p.ownerId, // v1.4.0 (ISS-112)
|
||||||
|
ownerName: usernameByPlayerId.get(p.ownerId) ?? p.ownerId, // missing players row → raw id, never a crash
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
packages: packageRows.filter((p) => p.status !== 'open' || pool.has(p.originIcao)).map((p) => ({
|
packages: packageRows.filter((p) => p.status !== 'open' || pool.has(p.originIcao)).map((p) => ({
|
||||||
@@ -215,6 +235,11 @@ export async function getStateSnapshot(playerId: string): Promise<StateSnapshot>
|
|||||||
planeId: p.planeId,
|
planeId: p.planeId,
|
||||||
createdSimTime: p.createdSimTime,
|
createdSimTime: p.createdSimTime,
|
||||||
expiresRealMs: p.status === 'open' ? p.createdRealMs + JOB_LIFETIME_MS : null,
|
expiresRealMs: p.status === 'open' ? p.createdRealMs + JOB_LIFETIME_MS : null,
|
||||||
|
// v1.4.0 (ISS-112): carrier = the CARRYING PLANE's owner — unassigned packages stay null.
|
||||||
|
carrierName: (() => {
|
||||||
|
const plane = p.planeId ? planeById.get(p.planeId) : undefined;
|
||||||
|
return plane ? usernameByPlayerId.get(plane.ownerId) ?? plane.ownerId : null;
|
||||||
|
})(),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-28
@@ -55,8 +55,8 @@ describe('planesGeoJson', () => {
|
|||||||
const fc = planesGeoJson(
|
const fc = planesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'plane-starter', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: [] },
|
{ id: 'plane-starter', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
{ id: 'plane-2', model: 'atr72', status: 'flying', fuel: 4000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: 'EDDV', destName: 'Hannover Airport', departureSimTime: 1000, lat: 51.2, lon: 9.1, health: 100, repairUntilSimTime: null, queue: [] },
|
{ id: 'plane-2', model: 'atr72', status: 'flying', fuel: 4000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: 'EDDV', destName: 'Hannover Airport', departureSimTime: 1000, lat: 51.2, lon: 9.1, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS, // non-crossing leg — the unwrap is a no-op, positions stay as polled
|
AIRPORTS, // non-crossing leg — the unwrap is a no-op, positions stay as polled
|
||||||
@@ -83,7 +83,7 @@ describe('planesGeoJson', () => {
|
|||||||
snapshot({
|
snapshot({
|
||||||
catalog: [],
|
catalog: [],
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'p1', model: 'mystery', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [] },
|
{ id: 'p1', model: 'mystery', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
@@ -95,7 +95,7 @@ describe('planesGeoJson', () => {
|
|||||||
const fc = planesGeoJson(
|
const fc = planesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: 0, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [] },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: 0, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS, // no destIcao — the unwrap guard skips it, position stays as polled
|
AIRPORTS, // no destIcao — the unwrap guard skips it, position stays as polled
|
||||||
@@ -113,7 +113,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
|
|||||||
const fc = planesGeoJson(
|
const fc = planesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NZAA', locationName: 'Auckland', destIcao: 'NFTF', destName: "Fua'amotu", departureSimTime: 0, lat: -29.5, lon: -179.5, health: 100, repairUntilSimTime: null, queue: [] },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NZAA', locationName: 'Auckland', destIcao: 'NFTF', destName: "Fua'amotu", departureSimTime: 0, lat: -29.5, lon: -179.5, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
@@ -127,7 +127,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
|
|||||||
const fc = planesGeoJson(
|
const fc = planesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NFTF', locationName: "Fua'amotu", destIcao: 'NZAA', destName: 'Auckland', departureSimTime: 0, lat: -29.5, lon: 179.5, health: 100, repairUntilSimTime: null, queue: [] },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NFTF', locationName: "Fua'amotu", destIcao: 'NZAA', destName: 'Auckland', departureSimTime: 0, lat: -29.5, lon: 179.5, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
@@ -148,7 +148,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
|
|||||||
const fc = planesGeoJson(
|
const fc = planesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'NZAA', locationName: 'A', destIcao: 'NFTF', destName: 'B', departureSimTime: 0, lat: uLat, lon: wLon, health: 100, repairUntilSimTime: null, queue: [] },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'NZAA', locationName: 'A', destIcao: 'NFTF', destName: 'B', departureSimTime: 0, lat: uLat, lon: wLon, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
@@ -161,7 +161,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
|
|||||||
const fc = planesGeoJson(
|
const fc = planesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'p1', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'NFTF', locationName: "Fua'amotu", destIcao: null, destName: null, departureSimTime: null, lat: nftf.lat, lon: nftf.lon, health: 100, repairUntilSimTime: null, queue: [] },
|
{ id: 'p1', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'NFTF', locationName: "Fua'amotu", destIcao: null, destName: null, departureSimTime: null, lat: nftf.lat, lon: nftf.lon, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
@@ -173,7 +173,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
|
|||||||
const fc = planesGeoJson(
|
const fc = planesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NZAA', locationName: 'Auckland', destIcao: 'ZZZZ', destName: '?', departureSimTime: 0, lat: -29.5, lon: -179.5, health: 100, repairUntilSimTime: null, queue: [] },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NZAA', locationName: 'Auckland', destIcao: 'ZZZZ', destName: '?', departureSimTime: 0, lat: -29.5, lon: -179.5, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
@@ -185,7 +185,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
|
|||||||
const fc = planesGeoJson(
|
const fc = planesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'F', destIcao: 'LEMD', destName: 'M', departureSimTime: 0, lat: 45.2, lon: -1.9, health: 100, repairUntilSimTime: null, queue: [] },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'F', destIcao: 'LEMD', destName: 'M', departureSimTime: 0, lat: 45.2, lon: -1.9, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
@@ -197,7 +197,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
|
|||||||
const fc = planesGeoJson(
|
const fc = planesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'plane-starter', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NZAA', locationName: 'Auckland', destIcao: 'NFTF', destName: "Fua'amotu", departureSimTime: 0, lat: -29.5, lon: -179.5, health: 100, repairUntilSimTime: null, queue: [] },
|
{ id: 'plane-starter', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NZAA', locationName: 'Auckland', destIcao: 'NFTF', destName: "Fua'amotu", departureSimTime: 0, lat: -29.5, lon: -179.5, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
@@ -217,7 +217,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
|
|||||||
const fc = routesGeoJson(
|
const fc = routesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: [] },
|
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
@@ -229,7 +229,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
|
|||||||
const fc = routesGeoJson(
|
const fc = routesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [] },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
@@ -246,7 +246,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
|
|||||||
const fc = routesGeoJson(
|
const fc = routesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [] },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
@@ -258,8 +258,8 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
|
|||||||
const fc = routesGeoJson(
|
const fc = routesGeoJson(
|
||||||
snapshot({
|
snapshot({
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [] },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 52.4, lon: 9.6, health: 100, repairUntilSimTime: null, queue: [] },
|
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 52.4, lon: 9.6, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
@@ -273,7 +273,7 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
|
|||||||
const fc = queuedRoutesGeoJson(
|
const fc = queuedRoutesGeoJson(
|
||||||
[
|
[
|
||||||
// Pre-built route while grounded: the line starts at the plane's current airport, not at any dest.
|
// Pre-built route while grounded: the line starts at the plane's current airport, not at any dest.
|
||||||
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: ['EHAM'] },
|
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: ['EHAM'], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
);
|
);
|
||||||
@@ -288,7 +288,7 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
|
|||||||
it('skips a grounded plane whose locationIcao is missing from the airport index', () => {
|
it('skips a grounded plane whose locationIcao is missing from the airport index', () => {
|
||||||
const fc = queuedRoutesGeoJson(
|
const fc = queuedRoutesGeoJson(
|
||||||
[
|
[
|
||||||
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: ['EHAM'] },
|
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: ['EHAM'], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
);
|
);
|
||||||
@@ -298,7 +298,7 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
|
|||||||
it('draws nothing for a repairing plane, even with a non-empty queue', () => {
|
it('draws nothing for a repairing plane, even with a non-empty queue', () => {
|
||||||
const fc = queuedRoutesGeoJson(
|
const fc = queuedRoutesGeoJson(
|
||||||
[
|
[
|
||||||
{ id: 'p1', model: 'atr72', status: 'repairing', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 0, repairUntilSimTime: 1000, queue: ['EHAM'] },
|
{ id: 'p1', model: 'atr72', status: 'repairing', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 0, repairUntilSimTime: 1000, queue: ['EHAM'], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
);
|
);
|
||||||
@@ -308,7 +308,7 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
|
|||||||
it('draws nothing for a flying plane with an empty queue', () => {
|
it('draws nothing for a flying plane with an empty queue', () => {
|
||||||
const fc = queuedRoutesGeoJson(
|
const fc = queuedRoutesGeoJson(
|
||||||
[
|
[
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [] },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
);
|
);
|
||||||
@@ -319,7 +319,7 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
|
|||||||
const fc = queuedRoutesGeoJson(
|
const fc = queuedRoutesGeoJson(
|
||||||
[
|
[
|
||||||
// Active leg EDDF→EHAM stays in routesGeoJson; the queue line is ONLY EHAM → EDDV → LEMD.
|
// Active leg EDDF→EHAM stays in routesGeoJson; the queue line is ONLY EHAM → EDDV → LEMD.
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['EDDV', 'LEMD'] },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['EDDV', 'LEMD'], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
);
|
);
|
||||||
@@ -336,8 +336,8 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
|
|||||||
it('skips a plane whose dest or a queued ICAO is missing from the airport index', () => {
|
it('skips a plane whose dest or a queued ICAO is missing from the airport index', () => {
|
||||||
const fc = queuedRoutesGeoJson(
|
const fc = queuedRoutesGeoJson(
|
||||||
[
|
[
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'ZZZZ', destName: '?', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['EHAM'] },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'ZZZZ', destName: '?', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['EHAM'], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['ZZZZ'] },
|
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['ZZZZ'], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
);
|
);
|
||||||
@@ -347,9 +347,9 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
|
|||||||
it('one feature per qualifying plane — flying and grounded queues draw; empty-queue planes do not', () => {
|
it('one feature per qualifying plane — flying and grounded queues draw; empty-queue planes do not', () => {
|
||||||
const fc = queuedRoutesGeoJson(
|
const fc = queuedRoutesGeoJson(
|
||||||
[
|
[
|
||||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['LEMD'] },
|
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['LEMD'], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EHAM', locationName: 'A', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 52.3, lon: 4.7, health: 100, repairUntilSimTime: null, queue: [] },
|
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EHAM', locationName: 'A', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 52.3, lon: 4.7, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
{ id: 'p3', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: null, destName: null, departureSimTime: null, lat: 52.46, lon: 9.68, health: 100, repairUntilSimTime: null, queue: ['LEMD'] },
|
{ id: 'p3', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: null, destName: null, departureSimTime: null, lat: 52.46, lon: 9.68, health: 100, repairUntilSimTime: null, queue: ['LEMD'], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
);
|
);
|
||||||
@@ -491,7 +491,7 @@ describe('greatCircleArc — antimeridian crossing (ISS-120 v0.53.0)', () => {
|
|||||||
// leg 2 restarts at the RAW −175.1 — without the global re-unwrap the joint would jump ~360°.
|
// leg 2 restarts at the RAW −175.1 — without the global re-unwrap the joint would jump ~360°.
|
||||||
const fc = queuedRoutesGeoJson(
|
const fc = queuedRoutesGeoJson(
|
||||||
[
|
[
|
||||||
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'NZAA', locationName: 'A', destIcao: null, destName: null, departureSimTime: null, lat: -37.01, lon: 174.79, health: 100, repairUntilSimTime: null, queue: ['NFTF', 'NZCH'] },
|
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'NZAA', locationName: 'A', destIcao: null, destName: null, departureSimTime: null, lat: -37.01, lon: 174.79, health: 100, repairUntilSimTime: null, queue: ['NFTF', 'NZCH'], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
AIRPORTS,
|
AIRPORTS,
|
||||||
);
|
);
|
||||||
@@ -529,7 +529,7 @@ describe('NZAA→NFTF crossing leg — full pipeline on stored dataset coords (I
|
|||||||
const pos = positionAt(nzaa.lat, nzaa.lon, nftf.lat, nftf.lon, i / 50); // wrapped — exactly what the snapshot holds
|
const pos = positionAt(nzaa.lat, nzaa.lon, nftf.lat, nftf.lon, i / 50); // wrapped — exactly what the snapshot holds
|
||||||
const snap = snapshot({
|
const snap = snapshot({
|
||||||
planes: [
|
planes: [
|
||||||
{ id: 'plane-starter', model: 'atr72', status: 'flying', fuel: 1000, maxFuel: PLANE_TYPES.atr72.maxFuel, locationIcao: 'NZAA', locationName: 'Auckland International Airport', destIcao: 'NFTF', destName: "Fua'amotu International Airport", departureSimTime: 0, lat: pos.lat, lon: pos.lon, health: 100, repairUntilSimTime: null, queue: [] },
|
{ id: 'plane-starter', model: 'atr72', status: 'flying', fuel: 1000, maxFuel: PLANE_TYPES.atr72.maxFuel, locationIcao: 'NZAA', locationName: 'Auckland International Airport', destIcao: 'NFTF', destName: "Fua'amotu International Airport", departureSimTime: 0, lat: pos.lat, lon: pos.lon, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
lons.push(planesGeoJson(snap, index).features[0]!.geometry.coordinates[0]!);
|
lons.push(planesGeoJson(snap, index).features[0]!.geometry.coordinates[0]!);
|
||||||
|
|||||||
@@ -128,6 +128,8 @@ describe('OpenAPI spec', () => {
|
|||||||
'lon',
|
'lon',
|
||||||
'maxFuel',
|
'maxFuel',
|
||||||
'model',
|
'model',
|
||||||
|
'ownerId', // v1.4.0 (ISS-112) — owner's players.id
|
||||||
|
'ownerName', // v1.4.0 (ISS-112) — owner's username
|
||||||
'queue', // v0.25.0 (ISS-043) — upcoming legs not yet started
|
'queue', // v0.25.0 (ISS-043) — upcoming legs not yet started
|
||||||
'repairUntilSimTime', // v0.39.0 (ISS-069) — active repair gate; null when not repairing
|
'repairUntilSimTime', // v0.39.0 (ISS-069) — active repair gate; null when not repairing
|
||||||
'status',
|
'status',
|
||||||
@@ -137,6 +139,7 @@ describe('OpenAPI spec', () => {
|
|||||||
expect(Object.keys(pkg).sort()).toEqual([
|
expect(Object.keys(pkg).sort()).toEqual([
|
||||||
'cargoCount',
|
'cargoCount',
|
||||||
'cargoType',
|
'cargoType',
|
||||||
|
'carrierName', // v1.4.0 (ISS-112) — carrier's username via planeId → owner; null when unassigned
|
||||||
'createdSimTime',
|
'createdSimTime',
|
||||||
'deadlineSimTime',
|
'deadlineSimTime',
|
||||||
'destIcao',
|
'destIcao',
|
||||||
|
|||||||
@@ -934,6 +934,8 @@ export const openApiSpec = {
|
|||||||
description:
|
description:
|
||||||
'Sim-clock epoch ms when an active repair finishes (v0.39.0) — set with status "repairing", cleared by the engine\'s flip-back; null when not in repair.',
|
'Sim-clock epoch ms when an active repair finishes (v0.39.0) — set with status "repairing", cleared by the engine\'s flip-back; null when not in repair.',
|
||||||
},
|
},
|
||||||
|
ownerId: { type: 'string', description: 'Owner\'s players.id (v1.4.0, ISS-112) — the snapshot carries ALL planes in the shared world.' },
|
||||||
|
ownerName: { type: 'string', description: 'Owner\'s username (v1.4.0, ISS-112).' },
|
||||||
},
|
},
|
||||||
required: [
|
required: [
|
||||||
'id',
|
'id',
|
||||||
@@ -951,6 +953,8 @@ export const openApiSpec = {
|
|||||||
'queue',
|
'queue',
|
||||||
'health', // v0.39.0 (ISS-069) — structural health in [0, 100]
|
'health', // v0.39.0 (ISS-069) — structural health in [0, 100]
|
||||||
'repairUntilSimTime', // v0.39.0 (ISS-069) — active repair gate; null when not repairing
|
'repairUntilSimTime', // v0.39.0 (ISS-069) — active repair gate; null when not repairing
|
||||||
|
'ownerId', // v1.4.0 (ISS-112) — owner's players.id
|
||||||
|
'ownerName', // v1.4.0 (ISS-112) — owner's username
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
PackageState: {
|
PackageState: {
|
||||||
@@ -974,6 +978,7 @@ export const openApiSpec = {
|
|||||||
planeId: { type: ['string', 'null'] },
|
planeId: { type: ['string', 'null'] },
|
||||||
createdSimTime: { type: 'integer' },
|
createdSimTime: { type: 'integer' },
|
||||||
expiresRealMs: { type: ['integer', 'null'], description: 'Wall-clock epoch ms when an OPEN job expires (v0.65.0) — createdRealMs + lifetime; null for every non-open status.' },
|
expiresRealMs: { type: ['integer', 'null'], description: 'Wall-clock epoch ms when an OPEN job expires (v0.65.0) — createdRealMs + lifetime; null for every non-open status.' },
|
||||||
|
carrierName: { type: ['string', 'null'], description: 'Carrier name (v1.4.0, ISS-112) — players.username of the carrying plane\'s owner via planeId → planes.owner_id; null when unassigned.' },
|
||||||
},
|
},
|
||||||
required: [
|
required: [
|
||||||
'id',
|
'id',
|
||||||
@@ -991,6 +996,7 @@ export const openApiSpec = {
|
|||||||
'planeId',
|
'planeId',
|
||||||
'createdSimTime',
|
'createdSimTime',
|
||||||
'expiresRealMs', // v0.65.0 — wall-clock expiry of open jobs; null otherwise
|
'expiresRealMs', // v0.65.0 — wall-clock expiry of open jobs; null otherwise
|
||||||
|
'carrierName', // v1.4.0 (ISS-112) — carrier's username via planeId → owner; null when unassigned
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+18
-12
@@ -1,7 +1,7 @@
|
|||||||
import { randomInt, randomUUID } from 'node:crypto';
|
import { randomInt, randomUUID } from 'node:crypto';
|
||||||
import { eq, sql } from 'drizzle-orm';
|
import { eq, sql } from 'drizzle-orm';
|
||||||
import { getGameDb, persist, type GameDb } from '@/lib/db/client';
|
import { getGameDb, persist, type GameDb } from '@/lib/db/client';
|
||||||
import { LOCAL_PLAYER, getLocalPlayer, getPlayer, updatePlayerEconomy } from '@/lib/db/localPlayer';
|
import { getLocalPlayer, getPlayer, updatePlayerEconomy } from '@/lib/db/localPlayer';
|
||||||
import * as schema from '@/lib/db/schema';
|
import * as schema from '@/lib/db/schema';
|
||||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||||
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||||
@@ -9,7 +9,7 @@ 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 { 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 { SPECIALS } from '@/lib/game/specials';
|
||||||
import { getStateSnapshot, parseIcaoQueue, poolAirports, sseFrame } from '@/lib/game/state';
|
import { getStateSnapshot, parseIcaoQueue, poolAirports, sseFrame } from '@/lib/game/state';
|
||||||
import { broadcast } from '@/lib/sse';
|
import { broadcast, subscribedPlayerIds } from '@/lib/sse';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
JOB_LIFETIME_MS,
|
JOB_LIFETIME_MS,
|
||||||
@@ -385,9 +385,9 @@ export async function spawnPoolPackages(
|
|||||||
}));
|
}));
|
||||||
const rand = rng ?? mulberry32(randomInt(0, 2 ** 31));
|
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.
|
// 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 in v1.3.0 (ISS-110) — pools are SHARED world state
|
// ponytail: stays on the seeded 'local' row — pools are SHARED world state (packages have no
|
||||||
// (packages have no owner), so there is no single owning player whose rep should set job quality;
|
// owner), so there is no single owning player whose rep should set job quality; per-player job
|
||||||
// per-player job quality / pools is a later multiplayer step (#112).
|
// quality is a later multiplayer step.
|
||||||
const player = await getLocalPlayer(inst);
|
const player = await getLocalPlayer(inst);
|
||||||
|
|
||||||
let n = 0;
|
let n = 0;
|
||||||
@@ -436,15 +436,21 @@ export async function spawnPoolPackages(
|
|||||||
// package history is dropped, everything else matches GET /api/state exactly. Failures are logged,
|
// package history is dropped, everything else matches GET /api/state exactly. Failures are logged,
|
||||||
// not fatal: polling still works either way. Background ticks (~1 s) cover action mutations for
|
// not fatal: polling still works either way. Background ticks (~1 s) cover action mutations for
|
||||||
// other clients too.
|
// other clients too.
|
||||||
// v1.3.0 (ISS-110): the shared broadcast channel has no acting session — it carries the seeded
|
// v1.4.0 (ISS-112): per-player frames — the tick projects ONE snapshot per connected session's
|
||||||
// local player's account until per-player SSE frames land (#112). Session-scoped reads (/api/state,
|
// player id (subscribedPlayerIds; N is tiny) and fans each frame out to that player's group only,
|
||||||
// /api/events connect frame, action responses) all source their own row.
|
// so every client sees its own account row in every push. Same shared projection as GET /api/state
|
||||||
|
// (getStateSnapshot + sseFrame), so pushed frames can never drift from polled ones. A session whose
|
||||||
|
// players row vanished mid-stream is skipped per-tick with a log — the others keep flowing.
|
||||||
async function broadcastState(): Promise<void> {
|
async function broadcastState(): Promise<void> {
|
||||||
try {
|
const frames = new Map<string, string>();
|
||||||
broadcast(JSON.stringify(sseFrame(await getStateSnapshot(LOCAL_PLAYER.id))));
|
for (const playerId of subscribedPlayerIds()) {
|
||||||
} catch (err) {
|
try {
|
||||||
console.error('flight: state broadcast failed', err);
|
frames.set(playerId, JSON.stringify(sseFrame(await getStateSnapshot(playerId))));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('flight: state broadcast failed', err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (frames.size > 0) broadcast(frames);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tickNow(): Promise<schema.SimStateRow> {
|
export function tickNow(): Promise<schema.SimStateRow> {
|
||||||
|
|||||||
+38
-18
@@ -1,45 +1,65 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { broadcast, subscribe } from './sse';
|
import { broadcast, subscribedPlayerIds, subscribe } from './sse';
|
||||||
|
|
||||||
// ISS-129 (v0.57.0): the fan-out registry behind /api/events — plain Set semantics, so no DB or
|
// ISS-129 (v0.57.0): the fan-out registry behind /api/events — no DB or server needed. Subscribers
|
||||||
// server needed. Subscribers leak across tests in this file (module-level state), which is fine:
|
// leak across tests in this file (module-level state), which is fine: each test asserts on its own
|
||||||
// each test only asserts on its own arrays.
|
// arrays and uses distinct player ids where isolation matters. v1.4.0 (ISS-112): keyed by PLAYER ID
|
||||||
|
// — a frame for one player must never reach another player's streams.
|
||||||
|
|
||||||
describe('lib/sse', () => {
|
describe('lib/sse', () => {
|
||||||
it('delivers broadcasts to subscribers and stops after unsubscribe', () => {
|
it('delivers broadcasts to subscribers of the SAME player only, and stops after unsubscribe', () => {
|
||||||
const a: string[] = [];
|
const a: string[] = [];
|
||||||
const b: string[] = [];
|
const b: string[] = []; // same player as a — both get its frames
|
||||||
const unsubA = subscribe((t) => a.push(t));
|
const c: string[] = []; // different player — never sees their frames
|
||||||
const unsubB = subscribe((t) => b.push(t));
|
const unsubA = subscribe('p-a', (t) => a.push(t));
|
||||||
|
const unsubB = subscribe('p-a', (t) => b.push(t));
|
||||||
|
const unsubC = subscribe('p-c', (t) => c.push(t));
|
||||||
|
|
||||||
broadcast('x');
|
broadcast(new Map([['p-a', 'x']]));
|
||||||
expect(a).toEqual(['x']);
|
expect(a).toEqual(['x']);
|
||||||
expect(b).toEqual(['x']);
|
expect(b).toEqual(['x']); // same group
|
||||||
|
expect(c).toEqual([]); // other player's frame never crosses over
|
||||||
|
|
||||||
unsubA();
|
unsubA();
|
||||||
broadcast('y');
|
broadcast(new Map([['p-a', 'y'], ['p-c', 'z']]));
|
||||||
expect(a).toEqual(['x']); // unsubscribed — no more events
|
expect(a).toEqual(['x']); // unsubscribed — no more events
|
||||||
expect(b).toEqual(['x', 'y']);
|
expect(b).toEqual(['x', 'y']);
|
||||||
|
expect(c).toEqual(['z']);
|
||||||
unsubB();
|
unsubB();
|
||||||
|
unsubC();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a throwing subscriber does not break the others', () => {
|
it('subscribedPlayerIds lists exactly the players with live streams, dropping empty groups', () => {
|
||||||
const got: string[] = [];
|
const got: string[] = [];
|
||||||
subscribe(() => {
|
const unsub1 = subscribe('p-ids-1', (t) => got.push(t));
|
||||||
|
const unsub2 = subscribe('p-ids-1', (t) => got.push(t)); // second member of the same group
|
||||||
|
const unsub3 = subscribe('p-ids-2', (t) => got.push(t));
|
||||||
|
expect(subscribedPlayerIds().sort()).toEqual(['p-ids-1', 'p-ids-2']);
|
||||||
|
|
||||||
|
unsub1(); // p-ids-1 still has one member — id stays listed
|
||||||
|
expect(subscribedPlayerIds().sort()).toEqual(['p-ids-1', 'p-ids-2']);
|
||||||
|
unsub3(); // p-ids-2's last stream goes → its projections stop
|
||||||
|
expect(subscribedPlayerIds()).toEqual(['p-ids-1']);
|
||||||
|
unsub2();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a throwing subscriber does not break the others in its group', () => {
|
||||||
|
const got: string[] = [];
|
||||||
|
subscribe('p-boom', () => {
|
||||||
throw new Error('boom');
|
throw new Error('boom');
|
||||||
});
|
});
|
||||||
const unsub = subscribe((t) => got.push(t));
|
const unsub = subscribe('p-boom', (t) => got.push(t));
|
||||||
broadcast('z');
|
broadcast(new Map([['p-boom', 'z']]));
|
||||||
expect(got).toEqual(['z']);
|
expect(got).toEqual(['z']);
|
||||||
unsub();
|
unsub();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('unsubscribe is idempotent', () => {
|
it('unsubscribe is idempotent and broadcasting to an unknown player is a no-op', () => {
|
||||||
const got: string[] = [];
|
const got: string[] = [];
|
||||||
const unsub = subscribe((t) => got.push(t));
|
const unsub = subscribe('p-idem', (t) => got.push(t));
|
||||||
unsub();
|
unsub();
|
||||||
unsub(); // second call must not throw
|
unsub(); // second call must not throw
|
||||||
broadcast('w');
|
broadcast(new Map([['nobody-subscribed-here', 'w']])); // no group → dropped, no throw
|
||||||
expect(got).toEqual([]);
|
expect(got).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+35
-14
@@ -1,29 +1,50 @@
|
|||||||
// ISS-129 (v0.57.0): in-process fan-out registry for the SSE state stream (/api/events). The app
|
// ISS-129 (v0.57.0): in-process fan-out registry for the SSE state stream (/api/events). The app
|
||||||
// runs as one long-lived server process (`next start`; instrumentation.ts already keeps background
|
// runs as one long-lived server process (`next start`; instrumentation.ts already keeps background
|
||||||
// sim state alive) — no new dependencies, no WS upgrade needed. The Set lives on globalThis because
|
// sim state alive) — no new dependencies, no WS upgrade needed. The Map lives on globalThis because
|
||||||
// Next.js gives instrumentation.ts (where broadcast() fires from the background tick) and route
|
// Next.js gives instrumentation.ts (where broadcast() fires from the background tick) and route
|
||||||
// handlers (where subscribe() happens in /api/events) separate module instances; a plain `let` would
|
// handlers (where subscribe() happens in /api/events) separate module instances; a plain `let` would
|
||||||
// give each its own empty set and no push would ever be delivered.
|
// give each its own empty map and no push would ever be delivered. v1.4.0 (ISS-112): subscribers are
|
||||||
|
// keyed by PLAYER ID — the engine projects one snapshot per connected player and pushes each frame
|
||||||
|
// only to that player's group, so every client sees its own account row in every pushed frame.
|
||||||
|
|
||||||
type Push = (text: string) => void;
|
type Push = (text: string) => void;
|
||||||
|
|
||||||
const g = globalThis as unknown as { __flightSseSubscribers?: Set<Push> };
|
const g = globalThis as unknown as { __flightSseSubscribersByPlayer?: Map<string, Set<Push>> };
|
||||||
const subscribers: Set<Push> = (g.__flightSseSubscribers ??= new Set());
|
const subscribers: Map<string, Set<Push>> = (g.__flightSseSubscribersByPlayer ??= new Map());
|
||||||
|
|
||||||
export function subscribe(fn: Push): () => void {
|
export function subscribe(playerId: string, fn: Push): () => void {
|
||||||
subscribers.add(fn);
|
let group = subscribers.get(playerId);
|
||||||
|
if (!group) {
|
||||||
|
group = new Set();
|
||||||
|
subscribers.set(playerId, group);
|
||||||
|
}
|
||||||
|
group.add(fn);
|
||||||
return () => {
|
return () => {
|
||||||
subscribers.delete(fn);
|
const s = subscribers.get(playerId);
|
||||||
|
if (!s) return; // already unsubscribed — idempotent
|
||||||
|
s.delete(fn);
|
||||||
|
if (s.size === 0) subscribers.delete(playerId); // drop the empty group so projections stop for it
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call each subscriber safely — one dead stream must not take the others down.
|
// v1.4.0 (ISS-112): which player ids currently have at least one connected stream — the engine
|
||||||
export function broadcast(text: string): void {
|
// projects exactly these per tick (N is tiny; a projection is skipped when nobody listens).
|
||||||
for (const fn of [...subscribers]) {
|
export function subscribedPlayerIds(): string[] {
|
||||||
try {
|
return [...subscribers.keys()];
|
||||||
fn(text);
|
}
|
||||||
} catch {
|
|
||||||
// subscriber's stream is gone; its own cleanup unsubscribes it
|
// Call each subscriber safely — one dead stream must not take the others down. `frames` maps
|
||||||
|
// player id → pre-serialized frame text (the engine projects per player, this only fans out).
|
||||||
|
export function broadcast(frames: Map<string, string>): void {
|
||||||
|
for (const [playerId, text] of frames) {
|
||||||
|
const group = subscribers.get(playerId);
|
||||||
|
if (!group) continue; // unsubscribed between projection and push — fine
|
||||||
|
for (const fn of [...group]) {
|
||||||
|
try {
|
||||||
|
fn(text);
|
||||||
|
} catch {
|
||||||
|
// subscriber's stream is gone; its own cleanup unsubscribes it
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flight-ops",
|
"name": "flight-ops",
|
||||||
"version": "1.3.0",
|
"version": "1.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
Reference in New Issue
Block a user