v1.2.0: planes.owner_id + ownership checks + per-player onboarding (ISS-108, ISS-109)
CI / build + unit + e2e (pull_request) Successful in 13m46s

This commit is contained in:
2026-09-26 16:27:00 +00:00
parent ba2c36a3f8
commit ec26f9f366
25 changed files with 436 additions and 148 deletions
+45 -23
View File
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
import { and, eq, inArray } from 'drizzle-orm';
import { requireAuth } from '@/lib/auth';
import * as schema from '@/lib/db/schema';
import { getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
import { getPlayer, updatePlayerEconomy } from '@/lib/db/localPlayer';
import { persist, type GameDb } from '@/lib/db/client';
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm, initialBearingDeg } from '@/lib/geo';
@@ -35,6 +35,7 @@ import { HUB_ICAO } from './seed';
// 404 — unknown entity id
// 409 — valid ids but the current state forbids the action (wrong package/plane
// status, plane not at origin, no payload room, insufficient fuel/funds, last plane)
// 403 — v1.2.0 (ISS-109): the session player does not own the plane
// 400 — bad input that isn't an existence problem (unknown airport/model)
export class GameActionError extends Error {
@@ -54,13 +55,18 @@ async function airportByIcao(icao: string): Promise<{ icao: string; lat: number;
return { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon };
}
// v1.2.0 (ISS-109): every plane action requires the session player to own the plane — 403 otherwise.
function assertPlaneOwner(plane: schema.PlaneRow, playerId: string): void {
if (plane.ownerId !== playerId) throw new GameActionError(403, 'not your plane');
}
// --- Load (POST /api/packages/:id/load {planeId}) ---
// open → loaded, bound to the plane. The plane must be GROUNDED at the package's
// origin with enough payload room (v0.27.0: used kg = sum of weightKg over packages
// 'loaded' or 'expired' on it — expired cargo keeps occupying its space until
// delivered/abandoned).
export async function loadPackage(inst: GameDb, packageId: string, planeId: string): Promise<void> {
export async function loadPackage(inst: GameDb, packageId: string, planeId: string, playerId: string): Promise<void> {
const [pkgRows, planeRows] = await Promise.all([
inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)),
inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)),
@@ -72,6 +78,7 @@ export async function loadPackage(inst: GameDb, packageId: string, planeId: stri
}
const plane = planeRows[0];
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes
if (plane.status !== 'available') {
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can load cargo`);
}
@@ -111,10 +118,11 @@ export async function loadPackage(inst: GameDb, packageId: string, planeId: stri
// doesn't fit even a full tank still 409s (insufficient fuel), and an unaffordable top-up 409s
// with nothing mutated. v0.45.0 (ISS-089): a broken plane (health ≤ 0) cannot fly at all — it
// 409s before any refuel math, so fuel and cash stay untouched.
export async function flyPlane(inst: GameDb, planeId: string, destIcao: string): Promise<void> {
export async function flyPlane(inst: GameDb, planeId: string, destIcao: string, playerId: string): Promise<void> {
const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
const plane = planeRows[0];
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes
if (plane.status !== 'available') {
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can fly`);
}
@@ -142,7 +150,8 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
// any mutation, so a 409 leaves fuel and cash untouched.
const target = Math.min(plane.maxFuel, Math.max(plane.fuel, requiredL * 1.5));
const liters = Math.max(0, target - plane.fuel);
const player = await getLocalPlayer(inst);
// v1.2.0 (ISS-109): the acting player's own economy pays for the top-up (they own the plane).
const player = await getPlayer(inst, playerId);
const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(player.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100;
if (cost > 0 && player.cash < cost) {
throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(player.cash)}`);
@@ -174,7 +183,7 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
})
.where(eq(schema.planes.id, planeId));
if (cost > 0) {
await updateLocalPlayerEconomy(inst, { cash: Math.round((player.cash - cost) * 100) / 100 });
await updatePlayerEconomy(inst, playerId, { cash: Math.round((player.cash - cost) * 100) / 100 });
}
await persist(inst);
}
@@ -186,10 +195,11 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
// active leg still starts via flyPlane, and queued legs chain on landing when fuel
// covers them (lib/sim/engine.ts deliverPackages). Validation: known plane, ICAO
// in the airport dataset.
export async function queueLeg(inst: GameDb, planeId: string, destIcao: string): Promise<void> {
export async function queueLeg(inst: GameDb, planeId: string, destIcao: string, playerId: string): Promise<void> {
const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
const plane = planeRows[0];
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes
const fc = await loadAirportsGeoJson();
if (!fc.features.some((f) => f.properties.icao === destIcao)) {
@@ -216,10 +226,11 @@ export async function queueLeg(inst: GameDb, planeId: string, destIcao: string):
// Remove a NOT-yet-started leg from the queue by index. Only queued legs are
// cancellable — the active in-flight leg (destIcao) is never affected. Validation:
// known plane, integer index within [0, queue.length).
export async function cancelQueuedLeg(inst: GameDb, planeId: string, index: number): Promise<void> {
export async function cancelQueuedLeg(inst: GameDb, planeId: string, index: number, playerId: string): Promise<void> {
const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
const plane = planeRows[0];
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes
const queue = parseIcaoQueue(plane.queue);
if (index < 0 || index >= queue.length) {
@@ -236,7 +247,7 @@ export async function cancelQueuedLeg(inst: GameDb, planeId: string, index: numb
// scaling with time pressure (15% at creation → 60% at the deadline, see
// lib/game/penalty.ts); an already-expired package abandons for free. Cash never
// goes below €0. The slot is freed immediately.
export async function abandonPackage(inst: GameDb, packageId: string): Promise<{ penalty: number }> {
export async function abandonPackage(inst: GameDb, packageId: string, playerId: string): Promise<{ penalty: number }> {
const pkgRows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId));
const pkg = pkgRows[0];
if (!pkg) throw new GameActionError(404, `package ${packageId} not found`);
@@ -248,6 +259,7 @@ export async function abandonPackage(inst: GameDb, packageId: string): Promise<{
? (await inst.db.select().from(schema.planes).where(eq(schema.planes.id, pkg.planeId)))[0]
: undefined;
if (!plane) throw new GameActionError(409, `package ${packageId} is not bound to a plane`);
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes (via the bound plane)
if (plane.status !== 'available') {
throw new GameActionError(409, `the plane is in flight — packages can only be abandoned while it is grounded`);
}
@@ -256,8 +268,9 @@ export async function abandonPackage(inst: GameDb, packageId: string): Promise<{
if (!sim) throw new Error('sim_state row missing');
const penalty = abandonPenalty(pkg, sim.clockMs);
const player = await getLocalPlayer(inst);
await updateLocalPlayerEconomy(inst, { cash: Math.max(0, Math.round((player.cash - penalty) * 100) / 100) });
// v1.2.0 (ISS-109): the acting player's own economy pays the penalty.
const player = await getPlayer(inst, playerId);
await updatePlayerEconomy(inst, playerId, { cash: Math.max(0, Math.round((player.cash - penalty) * 100) / 100) });
await inst.db.update(schema.packages).set({ status: 'abandoned', planeId: null }).where(eq(schema.packages.id, packageId));
await persist(inst);
return { penalty };
@@ -268,11 +281,12 @@ export async function abandonPackage(inst: GameDb, packageId: string): Promise<{
// Buy a catalog model at its reputation-scaled price (v0.38.0, ISS-072 — discount grows with
// reputation, exactly full price at the starting value); it arrives 'available' with a full tank
// at the home base (EDDF).
export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId: string }> {
export async function buyPlane(inst: GameDb, model: string, playerId: string): Promise<{ planeId: string }> {
const type = PLANE_TYPES[model];
if (!type) throw new GameActionError(400, `unknown plane model "${model}"`);
const player = await getLocalPlayer(inst);
// v1.2.0 (ISS-109): the buyer's own economy pays and becomes the owner of the delivered plane.
const player = await getPlayer(inst, playerId);
// v0.38.0 (ISS-072): the EFFECTIVE cost drives both the funds check and the deduction.
const cost = Math.round(type.price * (1 - repRate(player.reputation, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)) * 100) / 100;
if (player.cash < cost) {
@@ -286,6 +300,7 @@ export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId:
const planeId = randomUUID();
await inst.db.insert(schema.planes).values({
id: planeId,
ownerId: playerId, // v1.2.0 (ISS-109): the buyer owns what they buy
model,
status: 'available',
fuel: type.maxFuel, // full tank on delivery
@@ -297,7 +312,7 @@ export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId:
departureSimTime: null,
health: 100, // new planes arrive at full health (v0.39.0, ISS-069)
});
await updateLocalPlayerEconomy(inst, { cash: Math.round((player.cash - cost) * 100) / 100 });
await updatePlayerEconomy(inst, playerId, { cash: Math.round((player.cash - cost) * 100) / 100 });
await persist(inst);
return { planeId };
}
@@ -306,10 +321,11 @@ export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId:
// Sell a grounded plane for SELL_FRACTION of its catalog price. The last
// remaining plane can never be sold — the company needs at least one aircraft.
export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout: number }> {
export async function sellPlane(inst: GameDb, planeId: string, playerId: string): Promise<{ payout: number }> {
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
const plane = rows[0];
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes
if (plane.status !== 'available') {
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be sold`);
}
@@ -328,16 +344,18 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
throw new GameActionError(409, 'the plane has queued legs — cancel queued legs first');
}
const all = await inst.db.select().from(schema.planes);
if (all.length <= 1) throw new GameActionError(409, 'cannot sell the last remaining plane');
// v1.2.0 (ISS-109): the guard is PER OWNER — you can't sell your last plane even if other
// players' planes exist in the world, and selling one of two of yours is fine.
const owned = await inst.db.select({ id: schema.planes.id }).from(schema.planes).where(eq(schema.planes.ownerId, playerId));
if (owned.length <= 1) throw new GameActionError(409, 'cannot sell the last remaining plane');
const type = PLANE_TYPES[plane.model];
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
const payout = Math.round(type.price * SELL_FRACTION * 100) / 100;
await inst.db.delete(schema.planes).where(eq(schema.planes.id, planeId));
const player = await getLocalPlayer(inst);
await updateLocalPlayerEconomy(inst, { cash: Math.round((player.cash + payout) * 100) / 100 });
const player = await getPlayer(inst, playerId);
await updatePlayerEconomy(inst, playerId, { cash: Math.round((player.cash + payout) * 100) / 100 });
await persist(inst);
return { payout };
}
@@ -350,10 +368,11 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
// and flips the plane to 'available' exactly when it reaches full health (the gate). Only GROUNDED
// planes can be repaired (409 otherwise); an already-full-health repair is a free no-op. 404 unknown
// plane; 409 not grounded / insufficient funds.
export async function repairPlane(inst: GameDb, planeId: string): Promise<{ cost: number }> {
export async function repairPlane(inst: GameDb, planeId: string, playerId: string): Promise<{ cost: number }> {
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
const plane = rows[0];
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes
if (plane.status !== 'available') {
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be repaired`);
}
@@ -362,7 +381,8 @@ export async function repairPlane(inst: GameDb, planeId: string): Promise<{ cost
const sim = (await inst.db.select().from(schema.simState))[0];
if (!sim) throw new Error('sim_state row missing');
const player = await getLocalPlayer(inst);
// v1.2.0 (ISS-109): the owner's own economy pays for the repair.
const player = await getPlayer(inst, playerId);
if (player.cash < REPAIR_COST) {
throw new GameActionError(409, `insufficient funds: repair costs ${REPAIR_COST}, you have ${Math.round(player.cash)}`);
}
@@ -371,7 +391,7 @@ export async function repairPlane(inst: GameDb, planeId: string): Promise<{ cost
await inst.db.update(schema.planes)
.set({ status: 'repairing', repairUntilSimTime: sim.clockMs + REPAIR_DURATION_MS })
.where(eq(schema.planes.id, planeId));
await updateLocalPlayerEconomy(inst, { cash: Math.round((player.cash - REPAIR_COST) * 100) / 100 });
await updatePlayerEconomy(inst, playerId, { cash: Math.round((player.cash - REPAIR_COST) * 100) / 100 });
await persist(inst);
return { cost: REPAIR_COST };
}
@@ -384,11 +404,13 @@ export async function repairPlane(inst: GameDb, planeId: string): Promise<{ cost
// its own status + {error}; anything unexpected is a 500.
// v1.1.0 (ISS-107): every action route funnels through here, so the requireAuth guard lives in ONE
// place — 401 before any game mutation when the X-Flight-Session header is missing/unknown/expired.
export async function performAction(req: Request, fn: (inst: GameDb) => Promise<unknown>): Promise<Response> {
// v1.2.0 (ISS-109): fn receives the session player's id as its second argument and threads it into
// the action for ownership checks + per-player economy — routes stay a pure pass-through.
export async function performAction(req: Request, fn: (inst: GameDb, playerId: string) => Promise<unknown>): Promise<Response> {
const auth = await requireAuth(req);
if (!auth.ok) return auth.response;
try {
await runAction(fn);
await runAction((inst) => fn(inst, auth.player.id));
return Response.json(await getStateSnapshot());
} catch (err) {
if (err instanceof GameActionError) {