Merge pull request 'v1.4.0: per-player snapshot + shared market view with owner info (ISS-112)' (#155) from feat/multiplayer-snapshot-market into master
CI / build + unit + e2e (push) Successful in 14m12s
CI / build + unit + e2e (push) Successful in 14m12s
This commit was merged in pull request #155.
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
# 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
|
||||
- **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.
|
||||
|
||||
@@ -2,8 +2,11 @@ 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 { createSession } from '@/lib/auth';
|
||||
import { createSession, hashPassword } from '@/lib/auth';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { getGameDb } from '@/lib/db/client';
|
||||
import { getPlayer } from '@/lib/db/localPlayer';
|
||||
import { tickNow } from '@/lib/sim/engine';
|
||||
import { GET } from './route';
|
||||
|
||||
let dir: string;
|
||||
@@ -49,8 +52,63 @@ describe('GET /api/events', () => {
|
||||
expect(text.startsWith('data: ')).toBe(true);
|
||||
const snap = JSON.parse(text.slice('data: '.length)) as Record<string, unknown>;
|
||||
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 {
|
||||
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=
|
||||
// (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
|
||||
// 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 HEARTBEAT_MS = 15_000;
|
||||
@@ -30,12 +33,12 @@ export async function GET(req: Request) {
|
||||
start(controller) {
|
||||
// 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
|
||||
// for the next push instead of seeing an out-of-order frame. v1.3.0 (ISS-110): the connect
|
||||
// frame carries THIS session's account row; later pushes come from the shared broadcast
|
||||
// (local player) until per-player frames land (#112).
|
||||
// for the next push instead of seeing an out-of-order frame. v1.4.0 (ISS-112): connect frame and
|
||||
// every later push carry THIS session's player projection — subscribe keys on auth.player.id so
|
||||
// the tick broadcast only sends this client frames projected for its own account row.
|
||||
getStateSnapshot(auth.player.id)
|
||||
.then((snapshot) => {
|
||||
unsubscribe = subscribe((text) => {
|
||||
unsubscribe = subscribe(auth.player.id, (text) => {
|
||||
try {
|
||||
// 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).
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 { eq } from 'drizzle-orm';
|
||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||
import { haversineKm } from '@/lib/geo';
|
||||
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>>;
|
||||
expect(planes.length).toBeGreaterThanOrEqual(1);
|
||||
for (const key of ['id', 'model', 'status', 'fuel', 'maxFuel', 'locationIcao', 'locationName', 'destIcao', 'destName', 'lat', 'lon']) {
|
||||
expect(planes[0], `plane.${key}`).toHaveProperty(key);
|
||||
for (const key of ['id', 'model', 'status', 'fuel', 'maxFuel', 'locationIcao', 'locationName', 'destIcao', 'destName', 'lat', 'lon', 'ownerId', 'ownerName']) {
|
||||
expect(planes[0], `plane.${key}`).toHaveProperty(key); // ownerId/ownerName: v1.4.0 (ISS-112)
|
||||
}
|
||||
expect(typeof planes[0].locationName).toBe('string');
|
||||
// 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>>;
|
||||
expect(packages.length).toBeGreaterThan(0);
|
||||
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'weightKg', 'distanceKm', 'deadlineSimTime', 'status']) {
|
||||
expect(packages[0], `package.${key}`).toHaveProperty(key);
|
||||
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'weightKg', 'distanceKm', 'deadlineSimTime', 'status', 'carrierName']) {
|
||||
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
|
||||
@@ -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.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 { getStateSnapshot } from '@/lib/game/state';
|
||||
|
||||
// v1.1.0 (ISS-107): guarded — 401 without a live X-Flight-Session. The snapshot shape is
|
||||
// unchanged; per-player snapshots are a later step (#112).
|
||||
// v1.1.0 (ISS-107): guarded — 401 without a live X-Flight-Session.
|
||||
// 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) {
|
||||
const auth = await requireAuth(req);
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
@@ -244,6 +244,15 @@ body {
|
||||
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 {
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
|
||||
@@ -157,6 +157,10 @@ export default function LeftPane({ state, loading, error, applySnapshot, onHover
|
||||
>
|
||||
<div className="plane-main">
|
||||
<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' ? (
|
||||
<>
|
||||
<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)}
|
||||
</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 ? (
|
||||
<LoadControls
|
||||
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
|
||||
repairUntilSimTime: null,
|
||||
queue: [],
|
||||
ownerId: 'local', // v1.4.0 (ISS-112) — owner info on every plane row
|
||||
ownerName: 'kamilkrzywda',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
@@ -48,6 +50,7 @@ function pkg(over: Partial<PackageState> & Pick<PackageState, 'id'>): PackageSta
|
||||
planeId: null,
|
||||
createdSimTime: 0,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.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, 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;
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ const basePlane = (id: string): PlaneState => ({
|
||||
health: 100, // v0.39.0 (ISS-069) — full-health fixture default
|
||||
repairUntilSimTime: null,
|
||||
queue: [],
|
||||
ownerId: 'local', // v1.4.0 (ISS-112) — owner info on every plane row
|
||||
ownerName: 'kamilkrzywda',
|
||||
});
|
||||
|
||||
const basePackage = (id: string): PackageState => ({
|
||||
@@ -44,6 +46,7 @@ const basePackage = (id: string): PackageState => ({
|
||||
status: 'open',
|
||||
planeId: null,
|
||||
createdSimTime: 0,
|
||||
carrierName: null, // v1.4.0 (ISS-112) — unassigned by default in fixtures
|
||||
});
|
||||
|
||||
const snapshot = (over: Partial<StateSnapshot> = {}): StateSnapshot => ({
|
||||
|
||||
@@ -20,6 +20,7 @@ const pkg = (status: PackageState['status'], id: string): PackageState => ({
|
||||
status,
|
||||
planeId: null,
|
||||
createdSimTime: 0,
|
||||
carrierName: null, // v1.4.0 (ISS-112) — unassigned by default in fixtures
|
||||
});
|
||||
|
||||
const snapshot = (packages: PackageState[]): StateSnapshot => ({
|
||||
|
||||
+28
-3
@@ -37,6 +37,10 @@ export interface PlaneState {
|
||||
health: number;
|
||||
// Sim-clock epoch ms when an active repair finishes (v0.39.0, ISS-069); null when not in repair.
|
||||
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
|
||||
@@ -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
|
||||
// countdown in the right pane; null for every non-open status (loaded cargo still uses deadlineSimTime).
|
||||
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 {
|
||||
@@ -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
|
||||
// shape as before ({cash, reputation}), sourced per-player. Callers without a session context
|
||||
// (the shared SSE broadcast) pass LOCAL_PLAYER.id until per-player frames land (#112).
|
||||
// shape as before ({cash, reputation}), sourced per-player. v1.4.0 (ISS-112): this IS the shared
|
||||
// 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> {
|
||||
const inst = await getGameDb();
|
||||
const { db } = inst;
|
||||
// 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
|
||||
// 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),
|
||||
db.select().from(schema.simState),
|
||||
db.select().from(schema.planes),
|
||||
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];
|
||||
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.
|
||||
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
|
||||
// departure values — live position and fuel are pure functions of the clock.
|
||||
const livePlane = (p: schema.PlaneRow) => {
|
||||
@@ -196,6 +214,8 @@ export async function getStateSnapshot(playerId: string): Promise<StateSnapshot>
|
||||
queue: parseIcaoQueue(p.queue),
|
||||
health: p.health, // v0.39.0 (ISS-069) — stored value; wear accumulates at landing only
|
||||
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) => ({
|
||||
@@ -215,6 +235,11 @@ export async function getStateSnapshot(playerId: string): Promise<StateSnapshot>
|
||||
planeId: p.planeId,
|
||||
createdSimTime: p.createdSimTime,
|
||||
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(
|
||||
snapshot({
|
||||
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-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-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: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||
],
|
||||
}),
|
||||
AIRPORTS, // non-crossing leg — the unwrap is a no-op, positions stay as polled
|
||||
@@ -83,7 +83,7 @@ describe('planesGeoJson', () => {
|
||||
snapshot({
|
||||
catalog: [],
|
||||
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,
|
||||
@@ -95,7 +95,7 @@ describe('planesGeoJson', () => {
|
||||
const fc = planesGeoJson(
|
||||
snapshot({
|
||||
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
|
||||
@@ -113,7 +113,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
|
||||
const fc = planesGeoJson(
|
||||
snapshot({
|
||||
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,
|
||||
@@ -127,7 +127,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
|
||||
const fc = planesGeoJson(
|
||||
snapshot({
|
||||
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,
|
||||
@@ -148,7 +148,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
|
||||
const fc = planesGeoJson(
|
||||
snapshot({
|
||||
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,
|
||||
@@ -161,7 +161,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
|
||||
const fc = planesGeoJson(
|
||||
snapshot({
|
||||
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,
|
||||
@@ -173,7 +173,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
|
||||
const fc = planesGeoJson(
|
||||
snapshot({
|
||||
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,
|
||||
@@ -185,7 +185,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
|
||||
const fc = planesGeoJson(
|
||||
snapshot({
|
||||
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,
|
||||
@@ -197,7 +197,7 @@ describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () =>
|
||||
const fc = planesGeoJson(
|
||||
snapshot({
|
||||
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,
|
||||
@@ -217,7 +217,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
|
||||
const fc = routesGeoJson(
|
||||
snapshot({
|
||||
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,
|
||||
@@ -229,7 +229,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
|
||||
const fc = routesGeoJson(
|
||||
snapshot({
|
||||
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,
|
||||
@@ -246,7 +246,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
|
||||
const fc = routesGeoJson(
|
||||
snapshot({
|
||||
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,
|
||||
@@ -258,8 +258,8 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
|
||||
const fc = routesGeoJson(
|
||||
snapshot({
|
||||
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: '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: '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: [], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||
],
|
||||
}),
|
||||
AIRPORTS,
|
||||
@@ -273,7 +273,7 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
|
||||
const fc = queuedRoutesGeoJson(
|
||||
[
|
||||
// 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,
|
||||
);
|
||||
@@ -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', () => {
|
||||
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,
|
||||
);
|
||||
@@ -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', () => {
|
||||
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,
|
||||
);
|
||||
@@ -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', () => {
|
||||
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,
|
||||
);
|
||||
@@ -319,7 +319,7 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
|
||||
const fc = queuedRoutesGeoJson(
|
||||
[
|
||||
// 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,
|
||||
);
|
||||
@@ -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', () => {
|
||||
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: '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: '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'], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||
],
|
||||
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', () => {
|
||||
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: '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: '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: '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: [], 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'], ownerId: 'local', ownerName: 'kamilkrzywda' },
|
||||
],
|
||||
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°.
|
||||
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,
|
||||
);
|
||||
@@ -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 snap = snapshot({
|
||||
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]!);
|
||||
|
||||
@@ -128,6 +128,8 @@ describe('OpenAPI spec', () => {
|
||||
'lon',
|
||||
'maxFuel',
|
||||
'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
|
||||
'repairUntilSimTime', // v0.39.0 (ISS-069) — active repair gate; null when not repairing
|
||||
'status',
|
||||
@@ -137,6 +139,7 @@ describe('OpenAPI spec', () => {
|
||||
expect(Object.keys(pkg).sort()).toEqual([
|
||||
'cargoCount',
|
||||
'cargoType',
|
||||
'carrierName', // v1.4.0 (ISS-112) — carrier's username via planeId → owner; null when unassigned
|
||||
'createdSimTime',
|
||||
'deadlineSimTime',
|
||||
'destIcao',
|
||||
|
||||
@@ -934,6 +934,8 @@ export const openApiSpec = {
|
||||
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.',
|
||||
},
|
||||
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: [
|
||||
'id',
|
||||
@@ -951,6 +953,8 @@ export const openApiSpec = {
|
||||
'queue',
|
||||
'health', // v0.39.0 (ISS-069) — structural health in [0, 100]
|
||||
'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: {
|
||||
@@ -974,6 +978,7 @@ export const openApiSpec = {
|
||||
planeId: { type: ['string', 'null'] },
|
||||
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.' },
|
||||
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: [
|
||||
'id',
|
||||
@@ -991,6 +996,7 @@ export const openApiSpec = {
|
||||
'planeId',
|
||||
'createdSimTime',
|
||||
'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
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
+15
-9
@@ -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 { 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 { loadAirportsGeoJson } from '@/lib/airports';
|
||||
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 { SPECIALS } from '@/lib/game/specials';
|
||||
import { getStateSnapshot, parseIcaoQueue, poolAirports, sseFrame } from '@/lib/game/state';
|
||||
import { broadcast } from '@/lib/sse';
|
||||
import { broadcast, subscribedPlayerIds } from '@/lib/sse';
|
||||
|
||||
import {
|
||||
JOB_LIFETIME_MS,
|
||||
@@ -385,9 +385,9 @@ 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 in v1.3.0 (ISS-110) — 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 / pools is a later multiplayer step (#112).
|
||||
// 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);
|
||||
|
||||
let n = 0;
|
||||
@@ -436,16 +436,22 @@ export async function spawnPoolPackages(
|
||||
// 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
|
||||
// other clients too.
|
||||
// v1.3.0 (ISS-110): the shared broadcast channel has no acting session — it carries the seeded
|
||||
// local player's account until per-player SSE frames land (#112). Session-scoped reads (/api/state,
|
||||
// /api/events connect frame, action responses) all source their own row.
|
||||
// v1.4.0 (ISS-112): per-player frames — the tick projects ONE snapshot per connected session's
|
||||
// player id (subscribedPlayerIds; N is tiny) and fans each frame out to that player's group only,
|
||||
// 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> {
|
||||
const frames = new Map<string, string>();
|
||||
for (const playerId of subscribedPlayerIds()) {
|
||||
try {
|
||||
broadcast(JSON.stringify(sseFrame(await getStateSnapshot(LOCAL_PLAYER.id))));
|
||||
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> {
|
||||
return withLock(async () => {
|
||||
|
||||
+38
-18
@@ -1,45 +1,65 @@
|
||||
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
|
||||
// server needed. Subscribers leak across tests in this file (module-level state), which is fine:
|
||||
// each test only asserts on its own arrays.
|
||||
// ISS-129 (v0.57.0): the fan-out registry behind /api/events — no DB or server needed. Subscribers
|
||||
// leak across tests in this file (module-level state), which is fine: each test asserts on its own
|
||||
// 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', () => {
|
||||
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 b: string[] = [];
|
||||
const unsubA = subscribe((t) => a.push(t));
|
||||
const unsubB = subscribe((t) => b.push(t));
|
||||
const b: string[] = []; // same player as a — both get its frames
|
||||
const c: string[] = []; // different player — never sees their frames
|
||||
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(b).toEqual(['x']);
|
||||
expect(b).toEqual(['x']); // same group
|
||||
expect(c).toEqual([]); // other player's frame never crosses over
|
||||
|
||||
unsubA();
|
||||
broadcast('y');
|
||||
broadcast(new Map([['p-a', 'y'], ['p-c', 'z']]));
|
||||
expect(a).toEqual(['x']); // unsubscribed — no more events
|
||||
expect(b).toEqual(['x', 'y']);
|
||||
expect(c).toEqual(['z']);
|
||||
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[] = [];
|
||||
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');
|
||||
});
|
||||
const unsub = subscribe((t) => got.push(t));
|
||||
broadcast('z');
|
||||
const unsub = subscribe('p-boom', (t) => got.push(t));
|
||||
broadcast(new Map([['p-boom', 'z']]));
|
||||
expect(got).toEqual(['z']);
|
||||
unsub();
|
||||
});
|
||||
|
||||
it('unsubscribe is idempotent', () => {
|
||||
it('unsubscribe is idempotent and broadcasting to an unknown player is a no-op', () => {
|
||||
const got: string[] = [];
|
||||
const unsub = subscribe((t) => got.push(t));
|
||||
const unsub = subscribe('p-idem', (t) => got.push(t));
|
||||
unsub();
|
||||
unsub(); // second call must not throw
|
||||
broadcast('w');
|
||||
broadcast(new Map([['nobody-subscribed-here', 'w']])); // no group → dropped, no throw
|
||||
expect(got).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
+31
-10
@@ -1,25 +1,45 @@
|
||||
// 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
|
||||
// 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
|
||||
// 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;
|
||||
|
||||
const g = globalThis as unknown as { __flightSseSubscribers?: Set<Push> };
|
||||
const subscribers: Set<Push> = (g.__flightSseSubscribers ??= new Set());
|
||||
const g = globalThis as unknown as { __flightSseSubscribersByPlayer?: Map<string, Set<Push>> };
|
||||
const subscribers: Map<string, Set<Push>> = (g.__flightSseSubscribersByPlayer ??= new Map());
|
||||
|
||||
export function subscribe(fn: Push): () => void {
|
||||
subscribers.add(fn);
|
||||
export function subscribe(playerId: string, fn: Push): () => void {
|
||||
let group = subscribers.get(playerId);
|
||||
if (!group) {
|
||||
group = new Set();
|
||||
subscribers.set(playerId, group);
|
||||
}
|
||||
group.add(fn);
|
||||
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.
|
||||
export function broadcast(text: string): void {
|
||||
for (const fn of [...subscribers]) {
|
||||
// v1.4.0 (ISS-112): which player ids currently have at least one connected stream — the engine
|
||||
// projects exactly these per tick (N is tiny; a projection is skipped when nobody listens).
|
||||
export function subscribedPlayerIds(): string[] {
|
||||
return [...subscribers.keys()];
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -27,3 +47,4 @@ export function broadcast(text: string): void {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flight-ops",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
|
||||
Reference in New Issue
Block a user