v1.4.0: per-player snapshot + shared market view with owner info (ISS-112)
CI / build + unit + e2e (pull_request) Successful in 14m29s

This commit is contained in:
2026-09-26 18:37:11 +00:00
parent db77f03ce1
commit fe60bd0482
20 changed files with 315 additions and 89 deletions
+2 -1
View File
@@ -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;
+3
View File
@@ -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 => ({
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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]!);
+3
View File
@@ -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',
+6
View File
@@ -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
],
},
},
+18 -12
View File
@@ -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,15 +436,21 @@ 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> {
try {
broadcast(JSON.stringify(sseFrame(await getStateSnapshot(LOCAL_PLAYER.id))));
} catch (err) {
console.error('flight: state broadcast failed', err);
const frames = new Map<string, string>();
for (const playerId of subscribedPlayerIds()) {
try {
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> {
+38 -18
View File
@@ -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([]);
});
});
+35 -14
View File
@@ -1,29 +1,50 @@
// 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]) {
try {
fn(text);
} catch {
// subscriber's stream is gone; its own cleanup unsubscribes it
// 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 {
// subscriber's stream is gone; its own cleanup unsubscribes it
}
}
}
}