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
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user