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
+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;
})(),
})),
};
}