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:
+163
-82
@@ -5,7 +5,7 @@ import { eq } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { openGameDb, type GameDb } from '@/lib/db/client';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
|
||||
import { getPlayer, getLocalPlayer, updateLocalPlayerEconomy, updatePlayerEconomy } from '@/lib/db/localPlayer';
|
||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||
import { weatherFor } from '@/lib/weather';
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
repRate,
|
||||
SELL_FRACTION,
|
||||
} from './catalog';
|
||||
import { HUB_ICAO, STARTING_CLOCK_MS } from './seed';
|
||||
import { HUB_ICAO, STARTING_CASH, STARTING_CLOCK_MS, STARTING_REPUTATION } from './seed';
|
||||
import {
|
||||
abandonPackage,
|
||||
buyPlane,
|
||||
@@ -94,7 +94,7 @@ describe('loadPackage', () => {
|
||||
const inst = await freshDb('load-ok.db');
|
||||
await insertOpenPackage(inst);
|
||||
|
||||
await loadPackage(inst, 'p-open', 'plane-starter'); // seeded starter is grounded at EDDF
|
||||
await loadPackage(inst, 'p-open', 'plane-starter', 'local'); // seeded starter is grounded at EDDF
|
||||
|
||||
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-open')))[0];
|
||||
expect(row.status).toBe('loaded');
|
||||
@@ -107,19 +107,19 @@ describe('loadPackage', () => {
|
||||
const inst = await freshDb(`load-${status}.db`);
|
||||
await insertOpenPackage(inst, 'p-x', { status });
|
||||
|
||||
await expectActionError(loadPackage(inst, 'p-x', 'plane-starter'), 409, new RegExp(status));
|
||||
await expectActionError(loadPackage(inst, 'p-x', 'plane-starter', 'local'), 409, new RegExp(status));
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects an unknown package id with 404', async () => {
|
||||
const inst = await freshDb('load-punknown.db');
|
||||
await expectActionError(loadPackage(inst, 'nope', 'plane-starter'), 404);
|
||||
await expectActionError(loadPackage(inst, 'nope', 'plane-starter', 'local'), 404);
|
||||
});
|
||||
|
||||
it('rejects an unknown plane id with 404', async () => {
|
||||
const inst = await freshDb('load-ghost.db');
|
||||
await insertOpenPackage(inst);
|
||||
await expectActionError(loadPackage(inst, 'p-open', 'ghost'), 404);
|
||||
await expectActionError(loadPackage(inst, 'p-open', 'ghost', 'local'), 404);
|
||||
});
|
||||
|
||||
it('rejects a flying plane with 409', async () => {
|
||||
@@ -129,7 +129,7 @@ describe('loadPackage', () => {
|
||||
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter'), 409);
|
||||
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter', 'local'), 409);
|
||||
});
|
||||
|
||||
it('rejects a plane that is not at the origin with 409', async () => {
|
||||
@@ -137,7 +137,7 @@ describe('loadPackage', () => {
|
||||
await insertOpenPackage(inst); // originates at EDDF
|
||||
await inst.db.update(schema.planes).set({ locationIcao: 'LEMD' }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter'), 409, /at LEMD/);
|
||||
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter', 'local'), 409, /at LEMD/);
|
||||
});
|
||||
|
||||
it('rejects loading when the plane has no payload room (maxPayloadKg from catalog)', async () => {
|
||||
@@ -149,7 +149,7 @@ describe('loadPackage', () => {
|
||||
await insertOpenPackage(inst, `p-fill-${i}`, { status: 'loaded', planeId: 'plane-starter', weightKg: chunk });
|
||||
}
|
||||
|
||||
await expectActionError(loadPackage(inst, 'p-new', 'plane-starter'), 409, /no payload room/i);
|
||||
await expectActionError(loadPackage(inst, 'p-new', 'plane-starter', 'local'), 409, /no payload room/i);
|
||||
});
|
||||
|
||||
it('counts expired packages as occupying their payload too', async () => {
|
||||
@@ -160,7 +160,7 @@ describe('loadPackage', () => {
|
||||
await insertOpenPackage(inst, 'p-loaded-half', { status: 'loaded', planeId: 'plane-starter', weightKg: half });
|
||||
await insertOpenPackage(inst, 'p-expired-slot', { status: 'expired', planeId: 'plane-starter', weightKg: half });
|
||||
|
||||
await expectActionError(loadPackage(inst, 'p-new', 'plane-starter'), 409, /no payload room/i);
|
||||
await expectActionError(loadPackage(inst, 'p-new', 'plane-starter', 'local'), 409, /no payload room/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -168,7 +168,7 @@ describe('flyPlane', () => {
|
||||
it('sets flying + departure at current clock + dest_icao + origin coords', async () => {
|
||||
const inst = await freshDb('fly-ok.db');
|
||||
|
||||
await flyPlane(inst, 'plane-starter', 'LEMD');
|
||||
await flyPlane(inst, 'plane-starter', 'LEMD', 'local');
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('flying');
|
||||
@@ -188,17 +188,17 @@ describe('flyPlane', () => {
|
||||
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'EDDM'), 409);
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'EDDM', 'local'), 409);
|
||||
});
|
||||
|
||||
it('rejects an unknown plane id with 404', async () => {
|
||||
const inst = await freshDb('fly-ghost.db');
|
||||
await expectActionError(flyPlane(inst, 'ghost', 'LEMD'), 404);
|
||||
await expectActionError(flyPlane(inst, 'ghost', 'LEMD', 'local'), 404);
|
||||
});
|
||||
|
||||
it('rejects an ICAO not in the airport dataset with 400', async () => {
|
||||
const inst = await freshDb('fly-unknownicao.db');
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/);
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'ZZZZ', 'local'), 400, /unknown airport/);
|
||||
});
|
||||
|
||||
it('rejects a broken plane (health 0) with 409 and mutates nothing (v0.45.0, ISS-089)', async () => {
|
||||
@@ -207,7 +207,7 @@ describe('flyPlane', () => {
|
||||
await inst.db.update(schema.planes).set({ health: 0 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
const cashBefore = (await getLocalPlayer(inst)).cash;
|
||||
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /broken \(health 0\)/);
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD', 'local'), 409, /broken \(health 0\)/);
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('available'); // still grounded…
|
||||
@@ -219,7 +219,7 @@ describe('flyPlane', () => {
|
||||
const inst = await freshDb('fly-worn.db'); // EDDF → LEMD
|
||||
await inst.db.update(schema.planes).set({ health: 1 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
await flyPlane(inst, 'plane-starter', 'LEMD'); // no 409 — worn is not broken
|
||||
await flyPlane(inst, 'plane-starter', 'LEMD', 'local'); // no 409 — worn is not broken
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('flying');
|
||||
@@ -253,7 +253,7 @@ describe('flyPlane', () => {
|
||||
await updateLocalPlayerEconomy(inst, { reputation: 30 });
|
||||
const cashBefore = (await getLocalPlayer(inst)).cash;
|
||||
|
||||
await flyPlane(inst, 'plane-starter', 'LEMD');
|
||||
await flyPlane(inst, 'plane-starter', 'LEMD', 'local');
|
||||
|
||||
const rate = repRate(30, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
|
||||
expect(rate).toBeGreaterThan(0); // sanity pin on the formula input
|
||||
@@ -274,7 +274,7 @@ describe('flyPlane', () => {
|
||||
expect(required * 1.5).toBeLessThan(PLANE_TYPES.atr72.maxFuel); // sanity: the target stays below a full tank
|
||||
const cashBefore = (await getLocalPlayer(inst)).cash;
|
||||
|
||||
await flyPlane(inst, 'plane-starter', 'LEMD');
|
||||
await flyPlane(inst, 'plane-starter', 'LEMD', 'local');
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('flying');
|
||||
@@ -289,7 +289,7 @@ describe('flyPlane', () => {
|
||||
await inst.db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
await updateLocalPlayerEconomy(inst, { cash: 0 });
|
||||
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /insufficient funds/i);
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD', 'local'), 409, /insufficient funds/i);
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('available'); // still grounded…
|
||||
@@ -301,17 +301,17 @@ describe('flyPlane', () => {
|
||||
// UNNT ↔ LPPD are ~7670 km apart — far beyond the ATR's range, so no top-up can make it flyable.
|
||||
await inst.db.update(schema.planes).set({ locationIcao: 'UNNT' }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LPPD'), 409, /insufficient fuel/i);
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LPPD', 'local'), 409, /insufficient fuel/i);
|
||||
});
|
||||
|
||||
// --- Queue consumption on dispatch (v0.46.0, ISS-088) ---
|
||||
|
||||
it('consumes queue[0] when flying to the first queued leg — no phantom self-hop on landing', async () => {
|
||||
const inst = await freshDb('fly-consume.db');
|
||||
await queueLeg(inst, 'plane-starter', 'LEMD');
|
||||
await queueLeg(inst, 'plane-starter', 'EHAM');
|
||||
await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
|
||||
await queueLeg(inst, 'plane-starter', 'EHAM', 'local');
|
||||
|
||||
await flyPlane(inst, 'plane-starter', 'LEMD'); // the first queued leg
|
||||
await flyPlane(inst, 'plane-starter', 'LEMD', 'local'); // the first queued leg
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('flying');
|
||||
@@ -321,10 +321,10 @@ describe('flyPlane', () => {
|
||||
|
||||
it('keeps the queue intact when skipping ahead to a non-first queued leg (explicit override)', async () => {
|
||||
const inst = await freshDb('fly-skipahead.db');
|
||||
await queueLeg(inst, 'plane-starter', 'LEMD');
|
||||
await queueLeg(inst, 'plane-starter', 'EHAM');
|
||||
await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
|
||||
await queueLeg(inst, 'plane-starter', 'EHAM', 'local');
|
||||
|
||||
await flyPlane(inst, 'plane-starter', 'EHAM'); // not the first queued leg — an override
|
||||
await flyPlane(inst, 'plane-starter', 'EHAM', 'local'); // not the first queued leg — an override
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('flying');
|
||||
@@ -337,11 +337,11 @@ describe('queueLeg (v0.25.0, ISS-043)', () => {
|
||||
it('appends legs to the queue in order and persists them as JSON', async () => {
|
||||
const inst = await freshDb('qleg-ok.db');
|
||||
|
||||
await queueLeg(inst, 'plane-starter', 'LEMD');
|
||||
await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
|
||||
let plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(JSON.parse(plane.queue)).toEqual(['LEMD']);
|
||||
|
||||
await queueLeg(inst, 'plane-starter', 'EHAM'); // pre-building a route
|
||||
await queueLeg(inst, 'plane-starter', 'EHAM', 'local'); // pre-building a route
|
||||
plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']); // order preserved
|
||||
});
|
||||
@@ -352,7 +352,7 @@ describe('queueLeg (v0.25.0, ISS-043)', () => {
|
||||
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
await queueLeg(inst, 'plane-starter', 'EHAM');
|
||||
await queueLeg(inst, 'plane-starter', 'EHAM', 'local');
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('flying'); // still on its active leg…
|
||||
@@ -362,19 +362,19 @@ describe('queueLeg (v0.25.0, ISS-043)', () => {
|
||||
|
||||
it('rejects an unknown plane id with 404', async () => {
|
||||
const inst = await freshDb('qleg-ghost.db');
|
||||
await expectActionError(queueLeg(inst, 'ghost', 'LEMD'), 404);
|
||||
await expectActionError(queueLeg(inst, 'ghost', 'LEMD', 'local'), 404);
|
||||
});
|
||||
|
||||
it('rejects an ICAO not in the airport dataset with 400', async () => {
|
||||
const inst = await freshDb('qleg-unknownicao.db');
|
||||
await expectActionError(queueLeg(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/);
|
||||
await expectActionError(queueLeg(inst, 'plane-starter', 'ZZZZ', 'local'), 400, /unknown airport/);
|
||||
});
|
||||
|
||||
it('rejects a destination already in the queue with 409 (ISS-065)', async () => {
|
||||
const inst = await freshDb('qleg-dup.db');
|
||||
await queueLeg(inst, 'plane-starter', 'LEMD');
|
||||
await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
|
||||
|
||||
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD'), 409, /already in the flight queue/);
|
||||
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD', 'local'), 409, /already in the flight queue/);
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(JSON.parse(plane.queue)).toEqual(['LEMD']); // rejected leg not appended
|
||||
@@ -386,7 +386,7 @@ describe('queueLeg (v0.25.0, ISS-043)', () => {
|
||||
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD'), 409, /already flying to LEMD/);
|
||||
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD', 'local'), 409, /already flying to LEMD/);
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(JSON.parse(plane.queue)).toEqual([]); // rejected leg not appended
|
||||
@@ -394,8 +394,8 @@ describe('queueLeg (v0.25.0, ISS-043)', () => {
|
||||
|
||||
it('still appends a different known airport with success (ISS-065 sanity)', async () => {
|
||||
const inst = await freshDb('qleg-dupother.db');
|
||||
await queueLeg(inst, 'plane-starter', 'LEMD');
|
||||
await expect(queueLeg(inst, 'plane-starter', 'EHAM')).resolves.toBeUndefined();
|
||||
await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
|
||||
await expect(queueLeg(inst, 'plane-starter', 'EHAM', 'local')).resolves.toBeUndefined();
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']);
|
||||
@@ -405,11 +405,11 @@ describe('queueLeg (v0.25.0, ISS-043)', () => {
|
||||
describe('cancelQueuedLeg (v0.25.0, ISS-043)', () => {
|
||||
it('removes the leg at the given index and keeps the rest in order', async () => {
|
||||
const inst = await freshDb('qcancel-ok.db');
|
||||
await queueLeg(inst, 'plane-starter', 'LEMD');
|
||||
await queueLeg(inst, 'plane-starter', 'EHAM');
|
||||
await queueLeg(inst, 'plane-starter', 'EDDV');
|
||||
await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
|
||||
await queueLeg(inst, 'plane-starter', 'EHAM', 'local');
|
||||
await queueLeg(inst, 'plane-starter', 'EDDV', 'local');
|
||||
|
||||
await cancelQueuedLeg(inst, 'plane-starter', 1); // drop EHAM (the middle leg)
|
||||
await cancelQueuedLeg(inst, 'plane-starter', 1, 'local'); // drop EHAM (the middle leg)
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EDDV']);
|
||||
@@ -417,19 +417,19 @@ describe('cancelQueuedLeg (v0.25.0, ISS-043)', () => {
|
||||
|
||||
it('rejects an out-of-range index with 400', async () => {
|
||||
const inst = await freshDb('qcancel-oob.db');
|
||||
await queueLeg(inst, 'plane-starter', 'LEMD'); // one leg → only index 0 is valid
|
||||
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 1), 400);
|
||||
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', -1), 400);
|
||||
await queueLeg(inst, 'plane-starter', 'LEMD', 'local'); // one leg → only index 0 is valid
|
||||
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 1, 'local'), 400);
|
||||
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', -1, 'local'), 400);
|
||||
});
|
||||
|
||||
it('rejects cancelling from an empty queue with 400', async () => {
|
||||
const inst = await freshDb('qcancel-empty.db'); // seeded starter has no queued legs
|
||||
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 0), 400);
|
||||
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 0, 'local'), 400);
|
||||
});
|
||||
|
||||
it('rejects an unknown plane id with 404', async () => {
|
||||
const inst = await freshDb('qcancel-ghost.db');
|
||||
await expectActionError(cancelQueuedLeg(inst, 'ghost', 0), 404);
|
||||
await expectActionError(cancelQueuedLeg(inst, 'ghost', 0, 'local'), 404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -442,7 +442,7 @@ describe('abandonPackage', () => {
|
||||
const inst = await freshDb(name);
|
||||
// Always insert OPEN and load it through the real action…
|
||||
await insertOpenPackage(inst, 'p-ab', { reward: REWARD, createdSimTime: STARTING_CLOCK_MS, deadlineSimTime: STARTING_CLOCK_MS + WINDOW_MS });
|
||||
await loadPackage(inst, 'p-ab', 'plane-starter');
|
||||
await loadPackage(inst, 'p-ab', 'plane-starter', 'local');
|
||||
// …then flip to expired in place (what the tick would do once the deadline passes).
|
||||
if (status === 'expired') {
|
||||
await inst.db.update(schema.packages).set({ status: 'expired' }).where(eq(schema.packages.id, 'p-ab'));
|
||||
@@ -454,7 +454,7 @@ describe('abandonPackage', () => {
|
||||
const inst = await setupLoaded('ab-start.db');
|
||||
const cashBefore = (await getLocalPlayer(inst)).cash;
|
||||
|
||||
const { penalty } = await abandonPackage(inst, 'p-ab');
|
||||
const { penalty } = await abandonPackage(inst, 'p-ab', 'local');
|
||||
|
||||
expect(penalty).toBe(Math.round(REWARD * 0.15 * 100) / 100);
|
||||
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ab')))[0];
|
||||
@@ -468,7 +468,7 @@ describe('abandonPackage', () => {
|
||||
const inst = await setupLoaded('ab-mid.db');
|
||||
await inst.db.update(schema.simState).set({ clockMs: STARTING_CLOCK_MS + WINDOW_MS / 2 }); // runAction ticks first in prod
|
||||
|
||||
const { penalty } = await abandonPackage(inst, 'p-ab');
|
||||
const { penalty } = await abandonPackage(inst, 'p-ab', 'local');
|
||||
|
||||
expect(penalty).toBe(Math.round(REWARD * (0.15 + 0.45 * 0.5) * 100) / 100);
|
||||
});
|
||||
@@ -481,7 +481,7 @@ describe('abandonPackage', () => {
|
||||
const inst = await setupLoaded(name);
|
||||
await inst.db.update(schema.simState).set({ clockMs: clock });
|
||||
|
||||
const { penalty } = await abandonPackage(inst, 'p-ab');
|
||||
const { penalty } = await abandonPackage(inst, 'p-ab', 'local');
|
||||
expect(penalty).toBe(Math.round(REWARD * 0.6 * 100) / 100);
|
||||
}
|
||||
});
|
||||
@@ -490,7 +490,7 @@ describe('abandonPackage', () => {
|
||||
const inst = await setupLoaded('ab-expired.db', 'expired');
|
||||
const cashBefore = (await getLocalPlayer(inst)).cash;
|
||||
|
||||
const { penalty } = await abandonPackage(inst, 'p-ab');
|
||||
const { penalty } = await abandonPackage(inst, 'p-ab', 'local');
|
||||
|
||||
expect(penalty).toBe(0);
|
||||
expect((await getLocalPlayer(inst)).cash).toBe(cashBefore);
|
||||
@@ -502,7 +502,7 @@ describe('abandonPackage', () => {
|
||||
const inst = await setupLoaded('ab-floor.db');
|
||||
await updateLocalPlayerEconomy(inst, { cash: 10 }); // penalty is 150 > 10
|
||||
|
||||
const { penalty } = await abandonPackage(inst, 'p-ab');
|
||||
const { penalty } = await abandonPackage(inst, 'p-ab', 'local');
|
||||
|
||||
expect(penalty).toBe(150);
|
||||
expect((await getLocalPlayer(inst)).cash).toBe(0);
|
||||
@@ -514,19 +514,19 @@ describe('abandonPackage', () => {
|
||||
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
await expectActionError(abandonPackage(inst, 'p-ab'), 409, /in flight/i);
|
||||
await expectActionError(abandonPackage(inst, 'p-ab', 'local'), 409, /in flight/i);
|
||||
});
|
||||
|
||||
it.each(['open', 'delivered', 'abandoned'] as const)('rejects a %s package with 409', async (status) => {
|
||||
const inst = await freshDb(`ab-${status}.db`);
|
||||
await insertOpenPackage(inst, 'p-x', { status });
|
||||
|
||||
await expectActionError(abandonPackage(inst, 'p-x'), 409, new RegExp(status));
|
||||
await expectActionError(abandonPackage(inst, 'p-x', 'local'), 409, new RegExp(status));
|
||||
});
|
||||
|
||||
it('rejects an unknown id with 404', async () => {
|
||||
const inst = await freshDb('ab-unknown.db');
|
||||
await expectActionError(abandonPackage(inst, 'nope'), 404);
|
||||
await expectActionError(abandonPackage(inst, 'nope', 'local'), 404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -536,7 +536,7 @@ describe('buyPlane', () => {
|
||||
await updateLocalPlayerEconomy(inst, { cash: 500_000 });
|
||||
|
||||
const before = (await inst.db.select().from(schema.planes)).length;
|
||||
const { planeId } = await buyPlane(inst, 'atr72');
|
||||
const { planeId } = await buyPlane(inst, 'atr72', 'local');
|
||||
|
||||
const player = await getLocalPlayer(inst);
|
||||
expect(player.cash).toBe(500_000 - PLANE_TYPES.atr72.price);
|
||||
@@ -557,12 +557,12 @@ describe('buyPlane', () => {
|
||||
|
||||
it('rejects an unknown catalog model with 400', async () => {
|
||||
const inst = await freshDb('buy-unknown.db');
|
||||
await expectActionError(buyPlane(inst, 'boeing747'), 400, /unknown plane model/);
|
||||
await expectActionError(buyPlane(inst, 'boeing747', 'local'), 400, /unknown plane model/);
|
||||
});
|
||||
|
||||
it('rejects the purchase when cash is below the price with 409', async () => {
|
||||
const inst = await freshDb('buy-poor.db'); // seeded cash is 20k < 200k
|
||||
await expectActionError(buyPlane(inst, 'atr72'), 409, /insufficient funds/i);
|
||||
await expectActionError(buyPlane(inst, 'atr72', 'local'), 409, /insufficient funds/i);
|
||||
});
|
||||
|
||||
it('deducts the reputation-scaled cost at elevated rep (v0.38.0)', async () => {
|
||||
@@ -571,7 +571,7 @@ describe('buyPlane', () => {
|
||||
await updateLocalPlayerEconomy(inst, { cash: 500_000, reputation: 30 });
|
||||
|
||||
const before = (await inst.db.select().from(schema.planes)).length;
|
||||
await buyPlane(inst, 'atr72');
|
||||
await buyPlane(inst, 'atr72', 'local');
|
||||
|
||||
const rate = repRate(30, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT);
|
||||
expect(rate).toBeCloseTo(0.2, 12); // sanity pin on the formula input
|
||||
@@ -587,14 +587,14 @@ describe('buyPlane', () => {
|
||||
// rep 30 → discounted cost 160k: 170k is enough now, though it was not before v0.38.0.
|
||||
await updateLocalPlayerEconomy(inst, { cash: 170_000, reputation: 30 });
|
||||
|
||||
const { planeId } = await buyPlane(inst, 'atr72');
|
||||
const { planeId } = await buyPlane(inst, 'atr72', 'local');
|
||||
expect(planeId).toBeTruthy(); // the old full-price check would have rejected this
|
||||
const player = await getLocalPlayer(inst);
|
||||
expect(player.cash).toBeCloseTo(170_000 - 160_000, 6);
|
||||
|
||||
const inst2 = await freshDb('buy-boundary-poor.db'); // just below the discounted cost
|
||||
await updateLocalPlayerEconomy(inst2, { cash: 159_999.99, reputation: 30 });
|
||||
await expectActionError(buyPlane(inst2, 'atr72'), 409, /insufficient funds/i);
|
||||
await expectActionError(buyPlane(inst2, 'atr72', 'local'), 409, /insufficient funds/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -603,12 +603,12 @@ describe('sellPlane', () => {
|
||||
const inst = await freshDb('sell-ok.db');
|
||||
// A second plane so the starter is not "the last remaining one".
|
||||
await inst.db.insert(schema.planes).values({
|
||||
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||
id: 'plane-extra', ownerId: 'local', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
||||
});
|
||||
const cashBefore = (await getLocalPlayer(inst)).cash;
|
||||
|
||||
const { payout } = await sellPlane(inst, 'plane-extra');
|
||||
const { payout } = await sellPlane(inst, 'plane-extra', 'local');
|
||||
|
||||
expect(payout).toBe(Math.round(PLANE_TYPES.atr72.price * SELL_FRACTION * 100) / 100);
|
||||
const player = await getLocalPlayer(inst);
|
||||
@@ -621,22 +621,22 @@ describe('sellPlane', () => {
|
||||
it('rejects selling a flying plane with 409', async () => {
|
||||
const inst = await freshDb('sell-flying.db');
|
||||
await inst.db.insert(schema.planes).values({
|
||||
id: 'plane-extra', model: 'atr72', status: 'flying', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||
id: 'plane-extra', ownerId: 'local', model: 'atr72', status: 'flying', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||
locationIcao: HUB_ICAO, destIcao: 'LEMD', lat: 50, lon: 8, departureSimTime: T0,
|
||||
});
|
||||
|
||||
await expectActionError(sellPlane(inst, 'plane-extra'), 409, /flying/);
|
||||
await expectActionError(sellPlane(inst, 'plane-extra', 'local'), 409, /flying/);
|
||||
});
|
||||
|
||||
it('rejects selling a grounded plane that still carries cargo with 409 (v0.28.0)', async () => {
|
||||
const inst = await freshDb('sell-cargo.db');
|
||||
await inst.db.insert(schema.planes).values({
|
||||
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||
id: 'plane-extra', ownerId: 'local', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
||||
});
|
||||
await insertOpenPackage(inst, 'p-loaded', { status: 'loaded', planeId: 'plane-extra' });
|
||||
|
||||
await expectActionError(sellPlane(inst, 'plane-extra'), 409, /carrying cargo/);
|
||||
await expectActionError(sellPlane(inst, 'plane-extra', 'local'), 409, /carrying cargo/);
|
||||
const ids = (await inst.db.select().from(schema.planes)).map((p) => p.id);
|
||||
expect(ids).toContain('plane-extra'); // row untouched — the cargo stays bound to a real plane
|
||||
});
|
||||
@@ -644,24 +644,24 @@ describe('sellPlane', () => {
|
||||
it('rejects selling a grounded plane with queued legs with 409 (v0.46.0, ISS-088)', async () => {
|
||||
const inst = await freshDb('sell-queue.db');
|
||||
await inst.db.insert(schema.planes).values({
|
||||
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||
id: 'plane-extra', ownerId: 'local', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
||||
});
|
||||
await queueLeg(inst, 'plane-extra', 'LEMD');
|
||||
await queueLeg(inst, 'plane-extra', 'LEMD', 'local');
|
||||
|
||||
await expectActionError(sellPlane(inst, 'plane-extra'), 409, /cancel queued legs first/);
|
||||
await expectActionError(sellPlane(inst, 'plane-extra', 'local'), 409, /cancel queued legs first/);
|
||||
const plane = (await inst.db.select().from(schema.planes).where(eq(schema.planes.id, 'plane-extra')))[0];
|
||||
expect(JSON.parse(plane.queue)).toEqual(['LEMD']); // row untouched — the queue stays with a real plane
|
||||
});
|
||||
|
||||
it('rejects selling the last remaining plane with 409', async () => {
|
||||
const inst = await freshDb('sell-last.db'); // only the starter exists
|
||||
await expectActionError(sellPlane(inst, 'plane-starter'), 409, /last remaining/);
|
||||
await expectActionError(sellPlane(inst, 'plane-starter', 'local'), 409, /last remaining/);
|
||||
});
|
||||
|
||||
it('rejects an unknown id with 404', async () => {
|
||||
const inst = await freshDb('sell-unknown.db');
|
||||
await expectActionError(sellPlane(inst, 'ghost'), 404);
|
||||
await expectActionError(sellPlane(inst, 'ghost', 'local'), 404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -671,7 +671,7 @@ describe('repairPlane (v0.39.0, ISS-069; gradual since v0.45.0, ISS-089)', () =>
|
||||
await inst.db.update(schema.planes).set({ health: 42 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
const cashBefore = (await getLocalPlayer(inst)).cash;
|
||||
|
||||
const { cost } = await repairPlane(inst, 'plane-starter');
|
||||
const { cost } = await repairPlane(inst, 'plane-starter', 'local');
|
||||
|
||||
expect(cost).toBe(REPAIR_COST);
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
@@ -686,7 +686,7 @@ describe('repairPlane (v0.39.0, ISS-069; gradual since v0.45.0, ISS-089)', () =>
|
||||
const inst = await freshDb('repair-full.db'); // seeded starter at 100
|
||||
const cashBefore = (await getLocalPlayer(inst)).cash;
|
||||
|
||||
const { cost } = await repairPlane(inst, 'plane-starter');
|
||||
const { cost } = await repairPlane(inst, 'plane-starter', 'local');
|
||||
|
||||
expect(cost).toBe(0);
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
@@ -702,7 +702,7 @@ describe('repairPlane (v0.39.0, ISS-069; gradual since v0.45.0, ISS-089)', () =>
|
||||
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
await expectActionError(repairPlane(inst, 'plane-starter'), 409, /only grounded planes can be repaired/);
|
||||
await expectActionError(repairPlane(inst, 'plane-starter', 'local'), 409, /only grounded planes can be repaired/);
|
||||
});
|
||||
|
||||
it('rejects a plane that is already repairing with 409', async () => {
|
||||
@@ -711,23 +711,23 @@ describe('repairPlane (v0.39.0, ISS-069; gradual since v0.45.0, ISS-089)', () =>
|
||||
.set({ status: 'repairing', repairUntilSimTime: T0 + REPAIR_DURATION_MS })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
await expectActionError(repairPlane(inst, 'plane-starter'), 409, /only grounded planes can be repaired/);
|
||||
await expectActionError(repairPlane(inst, 'plane-starter', 'local'), 409, /only grounded planes can be repaired/);
|
||||
});
|
||||
|
||||
it('blocks load/fly/sell while repairing (the existing status guards)', async () => {
|
||||
const inst = await freshDb('repair-blocks.db');
|
||||
await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
await repairPlane(inst, 'plane-starter');
|
||||
await repairPlane(inst, 'plane-starter', 'local');
|
||||
|
||||
await insertOpenPackage(inst); // open package at the hub — loadable if the plane were grounded
|
||||
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter'), 409, /repairing/);
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /repairing/);
|
||||
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter', 'local'), 409, /repairing/);
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD', 'local'), 409, /repairing/);
|
||||
// A second plane so the "last remaining" guard cannot fire before the status one.
|
||||
await inst.db.insert(schema.planes).values({
|
||||
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||
id: 'plane-extra', ownerId: 'local', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
||||
});
|
||||
await expectActionError(sellPlane(inst, 'plane-starter'), 409, /repairing/);
|
||||
await expectActionError(sellPlane(inst, 'plane-starter', 'local'), 409, /repairing/);
|
||||
});
|
||||
|
||||
it('rejects the repair when cash is below REPAIR_COST with 409', async () => {
|
||||
@@ -735,11 +735,92 @@ describe('repairPlane (v0.39.0, ISS-069; gradual since v0.45.0, ISS-089)', () =>
|
||||
await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
await updateLocalPlayerEconomy(inst, { cash: REPAIR_COST - 1 });
|
||||
|
||||
await expectActionError(repairPlane(inst, 'plane-starter'), 409, /insufficient funds/i);
|
||||
await expectActionError(repairPlane(inst, 'plane-starter', 'local'), 409, /insufficient funds/i);
|
||||
});
|
||||
|
||||
it('rejects an unknown id with 404', async () => {
|
||||
const inst = await freshDb('repair-unknown.db');
|
||||
await expectActionError(repairPlane(inst, 'ghost'), 404);
|
||||
await expectActionError(repairPlane(inst, 'ghost', 'local'), 404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ownership (v1.2.0, ISS-109)', () => {
|
||||
// A second player row so per-player economy reads don't throw; the seeded starter is owned by 'local'.
|
||||
async function addPlayerB(inst: GameDb): Promise<void> {
|
||||
await inst.db.insert(schema.players).values({ id: 'b', provider: 'local', username: 'player-b', cash: STARTING_CASH, reputation: STARTING_REPUTATION });
|
||||
}
|
||||
|
||||
it('rejects every plane action with 403 when the session player does not own the plane', async () => {
|
||||
const inst = await freshDb('own-403.db');
|
||||
await addPlayerB(inst);
|
||||
await insertOpenPackage(inst, 'p-own');
|
||||
|
||||
await expectActionError(loadPackage(inst, 'p-own', 'plane-starter', 'b'), 403, /not your plane/);
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD', 'b'), 403, /not your plane/);
|
||||
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD', 'b'), 403, /not your plane/);
|
||||
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 0, 'b'), 403, /not your plane/);
|
||||
|
||||
// Load as the owner first so abandon has a bound package to check…
|
||||
await loadPackage(inst, 'p-own', 'plane-starter', 'local');
|
||||
await expectActionError(abandonPackage(inst, 'p-own', 'b'), 403, /not your plane/);
|
||||
|
||||
await expectActionError(sellPlane(inst, 'plane-starter', 'b'), 403, /not your plane/);
|
||||
await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||
await expectActionError(repairPlane(inst, 'plane-starter', 'b'), 403, /not your plane/);
|
||||
|
||||
// Nothing was mutated by the rejected attempts.
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('available');
|
||||
expect(JSON.parse(plane.queue)).toEqual([]);
|
||||
expect((await getLocalPlayer(inst)).cash).toBe(STARTING_CASH); // no money moved for either player
|
||||
});
|
||||
|
||||
it('rejects actions on another player\'s plane with 403 too (both directions)', async () => {
|
||||
const inst = await freshDb('own-403-rev.db');
|
||||
await addPlayerB(inst);
|
||||
await inst.db.insert(schema.planes).values({
|
||||
id: 'plane-b', ownerId: 'b', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
||||
});
|
||||
|
||||
await expectActionError(flyPlane(inst, 'plane-b', 'LEMD', 'local'), 403, /not your plane/);
|
||||
await expectActionError(sellPlane(inst, 'plane-b', 'local'), 403, /not your plane/);
|
||||
});
|
||||
|
||||
it('enforces the last-plane guard per owner — not on the world total', async () => {
|
||||
const inst = await freshDb('own-lastplane.db');
|
||||
await addPlayerB(inst);
|
||||
// 'b' owns two planes; 'local' only the starter. World total: three.
|
||||
for (const id of ['plane-b1', 'plane-b2']) {
|
||||
await inst.db.insert(schema.planes).values({
|
||||
id, ownerId: 'b', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Selling local's only plane is still forbidden even though the world has three planes…
|
||||
await expectActionError(sellPlane(inst, 'plane-starter', 'local'), 409, /last remaining/);
|
||||
// …while b can sell one of their two.
|
||||
const cashBefore = (await getPlayer(inst, 'b')).cash;
|
||||
const { payout } = await sellPlane(inst, 'plane-b1', 'b');
|
||||
|
||||
expect(payout).toBe(Math.round(PLANE_TYPES.atr72.price * SELL_FRACTION * 100) / 100);
|
||||
expect((await getPlayer(inst, 'b')).cash).toBeCloseTo(cashBefore + payout, 6); // b's own wallet pays out…
|
||||
expect((await getLocalPlayer(inst)).cash).toBe(STARTING_CASH); // …local's is untouched
|
||||
const ids = (await inst.db.select().from(schema.planes)).map((p) => p.id).sort();
|
||||
expect(ids).toEqual(['plane-b2', 'plane-starter']); // b1 gone; local's starter untouched
|
||||
});
|
||||
|
||||
it('stamps the buyer as owner and debits their own cash (not the local player\'s)', async () => {
|
||||
const inst = await freshDb('own-buy.db');
|
||||
await addPlayerB(inst);
|
||||
await updatePlayerEconomy(inst, 'b', { cash: 500_000 });
|
||||
|
||||
const { planeId } = await buyPlane(inst, 'atr72', 'b');
|
||||
|
||||
const bought = (await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)))[0];
|
||||
expect(bought.ownerId).toBe('b');
|
||||
expect((await getPlayer(inst, 'b')).cash).toBe(500_000 - PLANE_TYPES.atr72.price);
|
||||
expect((await getLocalPlayer(inst)).cash).toBe(STARTING_CASH); // local untouched
|
||||
});
|
||||
});
|
||||
|
||||
+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) {
|
||||
|
||||
+18
-11
@@ -360,19 +360,14 @@ function pickHub(airports: SeedAirport[]): SeedAirport {
|
||||
return airports.find((a) => a.icao === HUB_ICAO) ?? airports[0];
|
||||
}
|
||||
|
||||
export function buildSeed(input: {
|
||||
airports: SeedAirport[];
|
||||
rng: Rng;
|
||||
nowMs: number;
|
||||
}): SeedData {
|
||||
const { airports, rng, nowMs } = input;
|
||||
if (airports.length < 2) throw new Error('seed needs at least two airports');
|
||||
|
||||
// v1.2.0 (ISS-108): the starter plane, extracted from buildSeed so per-player onboarding can reuse
|
||||
// it — same values as the fresh-install seed's plane (no RNG draws), only the id differs. The
|
||||
// caller stamps ownership (planes.owner_id) at insert time; this stays a pure SeedPlane.
|
||||
export function buildStarterPlane(airports: SeedAirport[], id: string): SeedPlane {
|
||||
const type = PLANE_TYPES[STARTER_MODEL];
|
||||
const hub = pickHub(airports);
|
||||
|
||||
const plane: SeedPlane = {
|
||||
id: 'plane-starter',
|
||||
return {
|
||||
id,
|
||||
model: STARTER_MODEL,
|
||||
status: 'available',
|
||||
fuel: type.maxFuel, // starts full
|
||||
@@ -384,6 +379,18 @@ export function buildSeed(input: {
|
||||
queue: [], // no queued legs on first run (v0.25.0)
|
||||
health: 100, // full health on first run (v0.39.0, ISS-069)
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSeed(input: {
|
||||
airports: SeedAirport[];
|
||||
rng: Rng;
|
||||
nowMs: number;
|
||||
}): SeedData {
|
||||
const { airports, rng, nowMs } = input;
|
||||
if (airports.length < 2) throw new Error('seed needs at least two airports');
|
||||
|
||||
const plane = buildStarterPlane(airports, 'plane-starter');
|
||||
const hub = pickHub(airports);
|
||||
|
||||
// v0.65.0: the seed market is a full pool at the starter's home hub — the same per-airport
|
||||
// composition (POOL_BANDS) the runtime spawner maintains, so first run looks like any other tick.
|
||||
|
||||
Reference in New Issue
Block a user