v1.2.0: planes.owner_id + ownership checks + per-player onboarding (ISS-108, ISS-109)
CI / build + unit + e2e (pull_request) Successful in 13m46s
CI / build + unit + e2e (pull_request) Successful in 13m46s
This commit is contained in:
+45
-23
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user