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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user