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:
@@ -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
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
+18
-12
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user