v1.2.0: planes.owner_id + ownership checks + per-player onboarding (ISS-108, ISS-109) #153

Merged
kamilkrzywda merged 1 commits from feat/multiplayer-ownership into master 2026-09-26 18:41:07 +02:00
25 changed files with 436 additions and 148 deletions
Showing only changes of commit ec26f9f366 - Show all commits
+7
View File
@@ -1,5 +1,12 @@
# Changelog
## v1.2.0 — 2026-09-26 16:25
- **Multiplayer step 4/12 (ISS-108): per-player onboarding.** New registrants start with a starter plane at EDDF, not just cash: `buildStarterPlane(airports, id)` is extracted from `buildSeed` in `lib/game/seed.ts` (byte-identical values to the fresh-install seed — same model, full tank, hub location, no RNG draws; only the id differs) and reused by `POST /api/register`, which inserts it owned by the new player. The test-only mint hook does the same for custom usernames, so e2e-created accounts are onboarded like real ones. ponytail comment marks legacy players without planes (pre-v1.2.0 test mints) as out of scope — they keep exactly what they had.
- **Multiplayer step 5/12 (ISS-109): `planes.owner_id` + ownership checks.** New `owner_id TEXT NOT NULL` column in the raw DDL and Drizzle mirror; one-time guarded migration (`migrateColumns`, PRAGMA table_info check) backfills every pre-v1.2.0 row to `'local'` via SQLite's constant ALTER default, so a migrated world's planes all belong to the seeded local player exactly as before — idempotent on second open. All seven plane actions plus `repairPlane` now take the session player's id and reject foreign planes with 403 "not your plane" through one shared `assertPlaneOwner()` (checked right after the 404 fetch, so ownership errors win over state errors); `performAction` passes `auth.player.id` as its callback's second argument, keeping every route a pure pass-through. Economy is per-player: new `getPlayer(inst, id)` / `updatePlayerEconomy(inst, id, patch)` in `lib/db/localPlayer.ts` replace the local-only helpers at every action call site (a non-local buyer now debits their own cash; `getLocalPlayer` stays for the snapshot and engine until per-player views land). The sell-plane last-plane guard is PER OWNER — you can't sell your last plane even if other players' planes exist, and selling one of two of yours is fine. `buyPlane` stamps the buyer as owner of the delivered plane.
- **Mint hook default → kamilkrzywda.** The e2e specs play with the seeded world's planes by id (`plane-starter`), so the no-body mint now resolves to the existing local player that owns them after backfill — creating NO new rows; a custom username still creates a fresh account, now WITH onboarding. `e2e/auth.ts` and every spec are untouched: they keep working because the session they mint is the owner's.
- **OpenAPI.** The seven plane-targeted action endpoints document the new 403 (buy-plane has no plane target, so it gains none); path count unchanged at eighteen — `openapi.test.ts`'s per-endpoint error-code lists updated to match.
- **Tests.** Unit: new backfill migration test in `lib/db/client.test.ts` builds a pre-v1.2.0 file (two planes without owner_id) and verifies open() backfills both to `'local'`, does not re-seed, and is a no-op on second open (a post-migration owner change survives reopen); new ownership suite in `lib/game/actions.test.ts` — 403 for every action when the session player doesn't own the plane (both directions), per-owner last-plane guard against the world total, buyer stamped as owner with their own cash debited and local's untouched; register + mint route tests assert exactly one onboarding plane at EDDF owned by the new account. All 74 existing action call sites in `actions.test.ts` now pass `'local'`; no e2e spec changes needed (the minted session is the owner's).
## v1.1.0 — 2026-09-26 14:39
- **Multiplayer step 3/12 (ISS-107): every game route is session-guarded; new accounts can register.** `lib/auth.ts` grows a shared guard on top of the v0.71.0 session plumbing: `requireAuth(req)` (X-Flight-Session header) and `requireAuthSse(req)` (header OR `?session=` query — EventSource cannot set headers), both returning either `{ok, inst, user, player}` or a ready-made 401/500 response. The guard joins `players` on the account's username, so the seeded admin resolves to the v1.0.0 local player row; an account with no players row is a data error → 500. Coverage: `/api/state`, all eight action routes (one `performAction(req, fn)` call site in `lib/game/actions.ts` guards them all), and the SSE stream (`/api/events`, validated at connect — ponytail comment names the mid-stream ceiling). Exempt by design: `/api/version`, `/api/openapi`, `/api/airports`, `/api/login`, `/api/register`, `/api/logout`, `/api/me`, the admin CRUD (own guard), and every `/api/test/*` hook.
- **POST /api/register.** Strict body validation (exactly `username` + `password`; unknown fields 400); username lowercased, then 3–20 chars of `[a-z0-9_-]` else 400; password ≥8 chars else 400; 409 when taken. Creates the users row (scrypt via the same `hashPassword()` login verifies) AND a players row with the starter economy (`STARTING_CASH`/`STARTING_REPUTATION`) — one randomUUID shared by both rows — then mints a session immediately and returns `{sessionId, user}` like `/api/login`, so sign-up needs no second round-trip.
+1 -1
View File
@@ -27,5 +27,5 @@ export async function POST(req: Request) {
}
const model = input.model;
return performAction(req, (inst) => buyPlane(inst, model));
return performAction(req, (inst, playerId) => buyPlane(inst, model, playerId));
}
+1 -1
View File
@@ -60,7 +60,7 @@ describe('POST /api/market/sell-plane', () => {
const { db } = await getGameDb();
// A second, grounded plane so "flying" is the reason for rejection.
await 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: 'EDDF', destIcao: null, lat: 50.03, lon: 8.57, departureSimTime: null,
});
await db.update(schema.planes).set({ status: 'flying' }).where(eq(schema.planes.id, 'plane-extra'));
+1 -1
View File
@@ -27,5 +27,5 @@ export async function POST(req: Request) {
}
const planeId = input.planeId;
return performAction(req, (inst) => sellPlane(inst, planeId));
return performAction(req, (inst, playerId) => sellPlane(inst, planeId, playerId));
}
+1 -1
View File
@@ -9,5 +9,5 @@ import { abandonPackage, performAction } from '@/lib/game/actions';
export async function POST(req: Request, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params;
return performAction(req, (inst) => abandonPackage(inst, id));
return performAction(req, (inst, playerId) => abandonPackage(inst, id, playerId));
}
+1 -1
View File
@@ -31,5 +31,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ id: string }>
}
const planeId = input.planeId;
return performAction(req, (inst) => loadPackage(inst, id, planeId));
return performAction(req, (inst, playerId) => loadPackage(inst, id, planeId, playerId));
}
+1 -1
View File
@@ -31,5 +31,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ planeId: strin
}
const destIcao = input.destIcao;
return performAction(req, (inst) => flyPlane(inst, planeId, destIcao));
return performAction(req, (inst, playerId) => flyPlane(inst, planeId, destIcao, playerId));
}
@@ -30,5 +30,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ planeId: strin
}
const index = input.index as number;
return performAction(req, (inst) => cancelQueuedLeg(inst, planeId, index));
return performAction(req, (inst, playerId) => cancelQueuedLeg(inst, planeId, index, playerId));
}
+1 -1
View File
@@ -31,5 +31,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ planeId: strin
}
const destIcao = input.destIcao;
return performAction(req, (inst) => queueLeg(inst, planeId, destIcao));
return performAction(req, (inst, playerId) => queueLeg(inst, planeId, destIcao, playerId));
}
+1 -1
View File
@@ -8,5 +8,5 @@ import { performAction, repairPlane } from '@/lib/game/actions';
export async function POST(req: Request, ctx: { params: Promise<{ planeId: string }> }) {
const { planeId } = await ctx.params;
return performAction(req, (inst) => repairPlane(inst, planeId));
return performAction(req, (inst, playerId) => repairPlane(inst, planeId, playerId));
}
+5
View File
@@ -56,6 +56,11 @@ describe('POST /api/register', () => {
expect(Number(playerRow[2])).toBe(STARTING_CASH);
expect(Number(playerRow[3])).toBe(STARTING_REPUTATION);
// v1.2.0 (ISS-108): onboarding — exactly one starter plane at EDDF, owned by the new player.
const planes = inst.raw.exec('SELECT id, owner_id, location_icao FROM planes WHERE owner_id = ?', [String(userRow[0])])[0].values;
expect(planes).toHaveLength(1);
expect(String(planes[0][2])).toBe('EDDF');
// The minted session resolves to the new user.
const sess = inst.raw.exec('SELECT user_id FROM sessions WHERE id = ?', [body.sessionId])[0].values[0];
expect(String(sess[0])).toBe(String(userRow[0]));
+12 -1
View File
@@ -2,7 +2,8 @@ import { randomUUID } from 'node:crypto';
import * as schema from '@/lib/db/schema';
import { getGameDb, persist } from '@/lib/db/client';
import { createSession, hashPassword } from '@/lib/auth';
import { STARTING_CASH, STARTING_REPUTATION } from '@/lib/game/seed';
import { loadAirportsGeoJson } from '@/lib/airports';
import { buildStarterPlane, STARTING_CASH, STARTING_REPUTATION } from '@/lib/game/seed';
// v1.1.0 (ISS-107) — POST /api/register
// body { username: string, password: string } (both required; unknown fields 400).
@@ -64,6 +65,16 @@ export async function POST(req: Request) {
cash: STARTING_CASH,
reputation: STARTING_REPUTATION,
});
// v1.2.0 (ISS-108): onboarding — the new player's first login also grounds a starter plane at EDDF
// for them, reusing buildSeed's starter builder (same values as the fresh-install seed).
// ponytail: legacy players without planes (pre-v1.2.0 test mints) get none — out of scope here.
const fc = await loadAirportsGeoJson();
const starter = buildStarterPlane(
fc.features.map((f) => ({ icao: f.properties.icao, name: f.properties.name, lat: f.properties.lat, lon: f.properties.lon })),
randomUUID(),
);
const { queue, ...starterCols } = starter;
await inst.db.insert(schema.planes).values({ ...starterCols, ownerId: id, queue: JSON.stringify(queue ?? []) });
await persist(inst);
const sessionId = await createSession(inst, id);
+27 -7
View File
@@ -36,32 +36,52 @@ describe('POST /api/test/mint-player-session', () => {
expect(res.status).toBe(404);
});
it('creates the default test account + player row and mints a session when enabled', async () => {
it('mints a session for the seeded local player by default (v1.2.0: e2e plays with backfilled planes)', async () => {
vi.stubEnv('FLIGHT_TEST_HOOKS', '1');
const res = await post(); // no body → DEFAULT_USERNAME
const res = await post(); // no body → DEFAULT_USERNAME 'kamilkrzywda'
expect(res.status).toBe(200);
const body = (await res.json()) as { sessionId: string };
expect(body.sessionId).toMatch(/^[0-9a-f-]{36}$/);
const inst = await getGameDb();
// The session resolves to the EXISTING admin user — no new users/players rows are created.
const sess = inst.raw.exec('SELECT user_id FROM sessions WHERE id = ?', [body.sessionId])[0].values[0];
expect(String(sess[0])).toBe('admin-kamilkrzywda');
expect(Number(inst.raw.exec('SELECT COUNT(*) FROM users')[0].values[0][0])).toBe(1);
expect(Number(inst.raw.exec('SELECT COUNT(*) FROM players')[0].values[0][0])).toBe(1);
});
it('creates a fresh test account WITH onboarding (starter plane at EDDF) for a custom username', async () => {
vi.stubEnv('FLIGHT_TEST_HOOKS', '1');
const res = await post({ username: 'e2e-tester' });
expect(res.status).toBe(200);
const body = (await res.json()) as { sessionId: string };
const inst = await getGameDb();
const userRow = inst.raw.exec("SELECT id FROM users WHERE username = 'e2e-tester'")[0].values[0];
expect(userRow.length).toBe(1);
const playerRow = inst.raw.exec("SELECT id, cash FROM players WHERE username = 'e2e-tester'")[0].values[0];
expect(String(playerRow[0])).toBe(String(userRow[0])); // one UUID for both rows
// v1.2.0 (ISS-108): the new test player gets a starter plane at EDDF owned by them.
const planes = inst.raw.exec('SELECT id, owner_id, location_icao FROM planes WHERE owner_id = ?', [String(userRow[0])])[0].values;
expect(planes).toHaveLength(1);
expect(String(planes[0][2])).toBe('EDDF');
// The minted session resolves to the new user.
const sess = inst.raw.exec('SELECT user_id FROM sessions WHERE id = ?', [body.sessionId])[0].values[0];
expect(String(sess[0])).toBe(String(userRow[0]));
});
it('is idempotent — re-minting an existing username mints another session, no duplicate rows', async () => {
it('is idempotent — re-minting an existing username mints another session, no duplicate rows or planes', async () => {
vi.stubEnv('FLIGHT_TEST_HOOKS', '1');
const count = async () => Number((await getGameDb()).raw.exec("SELECT COUNT(*) FROM users WHERE username = 'e2e-tester'")[0].values[0][0]);
expect(await count()).toBe(1);
const count = async (sql: string) => Number((await getGameDb()).raw.exec(sql)[0].values[0][0]);
expect(await count("SELECT COUNT(*) FROM users WHERE username = 'e2e-tester'")).toBe(1);
const res = await post();
const res = await post({ username: 'e2e-tester' });
expect(res.status).toBe(200);
expect(await count()).toBe(1); // still one account…
expect(await count("SELECT COUNT(*) FROM users WHERE username = 'e2e-tester'")).toBe(1); // still one account…
expect(await count("SELECT COUNT(*) FROM planes p JOIN players pl ON p.owner_id = pl.id WHERE pl.username = 'e2e-tester'")).toBe(1); // …and exactly one onboarding plane
});
it('rejects bad usernames and unknown fields with 400', async () => {
+15 -3
View File
@@ -2,7 +2,8 @@ import { randomUUID } from 'node:crypto';
import * as schema from '@/lib/db/schema';
import { getGameDb, persist } from '@/lib/db/client';
import { createSession, hashPassword } from '@/lib/auth';
import { STARTING_CASH, STARTING_REPUTATION } from '@/lib/game/seed';
import { loadAirportsGeoJson } from '@/lib/airports';
import { buildStarterPlane, STARTING_CASH, STARTING_REPUTATION } from '@/lib/game/seed';
// TEST-ONLY hook (v1.1.0, ISS-107) — mints a session for a test player so the e2e suite can hit
// the guarded game routes WITHOUT any real credentials in test code (same reason as its sibling
@@ -11,10 +12,13 @@ import { STARTING_CASH, STARTING_REPUTATION } from '@/lib/game/seed';
// other deployment 404s here, like its /api/test/* siblings.
//
// POST /api/test/mint-player-session
// body { username?: string } — defaults to 'e2e-tester'; must match the register rules when given.
// body { username?: string } — defaults to 'kamilkrzywda' (v1.2.0, ISS-109: the e2e specs play
// with the seeded world's planes by id, so the minted session must resolve to the local player
// that owns them after the owner_id backfill); a custom username creates a fresh test account
// WITH onboarding (starter plane at EDDF) like /api/register. Must match the register rules when given.
// Returns { sessionId }.
const DEFAULT_USERNAME = 'e2e-tester';
const DEFAULT_USERNAME = 'kamilkrzywda';
// Test-only credential for the minted users row — e2e never logs in with it (sessions are minted
// server-side); a fixed value keeps the row debuggable from the admin panel.
const TEST_PASSWORD = 'e2e-mint-password-42';
@@ -70,6 +74,14 @@ export async function POST(req: Request) {
cash: STARTING_CASH,
reputation: STARTING_REPUTATION,
});
// v1.2.0 (ISS-108): same onboarding as /api/register — a starter plane at EDDF owned by the new player.
const fc = await loadAirportsGeoJson();
const starter = buildStarterPlane(
fc.features.map((f) => ({ icao: f.properties.icao, name: f.properties.name, lat: f.properties.lat, lon: f.properties.lon })),
randomUUID(),
);
const { queue, ...starterCols } = starter;
await inst.db.insert(schema.planes).values({ ...starterCols, ownerId: id, queue: JSON.stringify(queue ?? []) });
userId = id;
}
const sessionId = await createSession(inst, userId); // persists the rows + session together
+63
View File
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import initSqlJs from 'sql.js';
import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { WORLD_TIME_SCALE } from '@/lib/worldClock';
import { STARTING_CASH, STARTING_CLOCK_MS, STARTING_REPUTATION } from '@/lib/game/seed';
@@ -88,6 +89,40 @@ CREATE TABLE sim_state (
);
`;
// The v1.1.0 DDL — the last schema before ownership (ISS-109); used to build a "pre-v1.2.0" DB
// file for the owner_id backfill migration test.
const PRE_OWNERSHIP_DDL = `
CREATE TABLE planes (
id TEXT PRIMARY KEY NOT NULL,
model TEXT NOT NULL,
status TEXT NOT NULL,
fuel REAL NOT NULL,
max_fuel REAL NOT NULL,
location_icao TEXT NOT NULL,
dest_icao TEXT,
lat REAL NOT NULL,
lon REAL NOT NULL,
departure_sim_time INTEGER,
queue TEXT NOT NULL DEFAULT '[]',
health REAL NOT NULL DEFAULT 100,
repair_until_sim_time INTEGER
);
CREATE TABLE players (
id TEXT PRIMARY KEY NOT NULL,
provider TEXT NOT NULL,
username TEXT NOT NULL,
cash REAL NOT NULL,
reputation REAL NOT NULL
);
CREATE TABLE sim_state (
clock_ms INTEGER NOT NULL,
time_scale REAL NOT NULL,
paused INTEGER NOT NULL,
last_real_tick_ms INTEGER NOT NULL DEFAULT 0,
last_selected_time_scale REAL NOT NULL DEFAULT 1
);
`;
let dir: string;
beforeAll(async () => {
@@ -350,4 +385,32 @@ describe('openGameDb', () => {
expect(tables.has('account')).toBe(false);
expect(Number(second.raw.exec('SELECT COUNT(*) FROM players')[0].values[0][0])).toBe(1);
});
it('migrates a pre-v1.2.0 database: backfills planes.owner_id to the local player (one-shot)', async () => {
// A v1.1.0-era file: two planes without owner_id, the seeded local player row, live sim clock.
const SQL = await initSqlJs();
const raw = new SQL.Database();
raw.exec(PRE_OWNERSHIP_DDL);
raw.exec(`INSERT INTO planes VALUES ('plane-starter', 'atr72', 'available', 6300, 6300, 'EDDF', NULL, 50.03, 8.57, NULL, '[]', 100, NULL)`);
raw.exec(`INSERT INTO planes VALUES ('plane-bought', 'atr72', 'available', 6300, 6300, 'LEMD', NULL, 40.4, -3.6, NULL, '[]', 100, NULL)`);
raw.exec(`INSERT INTO players VALUES ('local', 'local', 'kamilkrzywda', 54020.34, 62.57)`);
raw.exec('INSERT INTO sim_state VALUES (1789706016605, 1, 0, 1789718713288, 1)');
const file = path.join(dir, 'pre-ownership.db');
await fs.writeFile(file, Buffer.from(raw.export()));
const inst = await openGameDb(file);
// Every legacy plane is backfilled to the local player…
const owners = (await inst.db.select().from(schema.planes)).map((p) => p.ownerId).sort();
expect(owners).toEqual(['local', 'local']);
// …and the world was NOT re-seeded (the players row already existed).
expect(await getLocalPlayer(inst)).toEqual({ ...LOCAL_PLAYER_ROW, cash: 54020.34, reputation: 62.57 });
// Second open is a no-op: an owner change made after the migration survives a reopen.
await inst.db.update(schema.planes).set({ ownerId: 'someone-else' }).where(eq(schema.planes.id, 'plane-bought'));
await persist(inst);
const second = await openGameDb(file);
const owners2 = (await second.db.select().from(schema.planes)).map((p) => p.ownerId).sort();
expect(owners2).toEqual(['local', 'someone-else']);
});
});
+11 -1
View File
@@ -23,6 +23,9 @@ export interface GameDb {
const DDL = `
CREATE TABLE IF NOT EXISTS planes (
id TEXT PRIMARY KEY NOT NULL,
-- v1.2.0 (ISS-109): owner's players.id — ownership checks in lib/game/actions.ts; legacy rows
-- backfill to 'local' via the guarded ALTER below (migrateColumns).
owner_id TEXT NOT NULL,
model TEXT NOT NULL,
status TEXT NOT NULL,
fuel REAL NOT NULL,
@@ -159,6 +162,12 @@ function migrateColumns(raw: RawSqlJsDatabase): void {
raw.exec('ALTER TABLE sim_state ADD COLUMN last_selected_time_scale REAL NOT NULL DEFAULT 1');
raw.exec('UPDATE sim_state SET last_selected_time_scale = time_scale');
}
// v1.2.0 (ISS-109): plane ownership — pre-v1.2.0 rows backfill to the seeded local player, so a
// migrated world's planes all belong to 'local' exactly as before (the column is NOT NULL with a
// constant default, which SQLite applies to every existing row at ALTER time).
if (!cols('planes').has('owner_id')) {
raw.exec(`ALTER TABLE planes ADD COLUMN owner_id TEXT NOT NULL DEFAULT 'local'`);
}
}
// v0.12.0 one-time migration: contracts world → packages world. Detected by the
@@ -267,7 +276,8 @@ async function seedEmpty(instance: GameDb): Promise<void> {
// SeedPlane.queue is a string[]; the column stores it as JSON text.
const { queue, ...planeCols } = data.plane;
await instance.db.insert(schema.planes).values({ ...planeCols, queue: JSON.stringify(queue ?? []) });
// v1.2.0 (ISS-109): the fresh-install starter plane belongs to the seeded local player.
await instance.db.insert(schema.planes).values({ ...planeCols, ownerId: LOCAL_PLAYER.id, queue: JSON.stringify(queue ?? []) });
await instance.db.insert(schema.packages).values(data.packages);
// v1.0.0 (ISS-105): the singleton economy now lives in a seeded local player row.
await instance.db.insert(schema.players).values({ ...LOCAL_PLAYER, cash: data.account.cash, reputation: data.account.reputation });
+17
View File
@@ -21,3 +21,20 @@ export async function updateLocalPlayerEconomy(
): Promise<void> {
await inst.db.update(schema.players).set(patch).where(eq(schema.players.id, LOCAL_PLAYER.id));
}
// v1.2.0 (ISS-109): per-player variants — the actions thread the session player's id through and
// read/write THAT row instead of the local one; getLocalPlayer stays for the snapshot/engine until
// per-player views land in a later multiplayer step.
export async function getPlayer(inst: GameDb, id: string): Promise<schema.PlayerRow> {
const [row] = await inst.db.select().from(schema.players).where(eq(schema.players.id, id));
if (!row) throw new Error(`player ${id} missing`);
return row;
}
export async function updatePlayerEconomy(
inst: GameDb,
id: string,
patch: { cash?: number; reputation?: number },
): Promise<void> {
await inst.db.update(schema.players).set(patch).where(eq(schema.players.id, id));
}
+4
View File
@@ -6,6 +6,10 @@ import { integer, real, sqliteTable, text } from 'drizzle-orm/sqlite-core';
export const planes = sqliteTable('planes', {
id: text('id').primaryKey().notNull(),
// v1.2.0 (ISS-109): owner's players.id — every plane action requires the session player to own
// the plane (403 otherwise). Legacy rows backfill to 'local' in open(); fresh installs and
// onboarding stamp it explicitly on insert.
ownerId: text('owner_id').notNull(),
model: text('model').notNull(),
// v0.39.0 (ISS-069): 'repairing' = grounded in the shop until repairUntilSimTime passes; the
// engine flips it back to 'available' on a tick. All status guards key off 'available'.
+163 -82
View File
@@ -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
View File
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
import { and, eq, inArray } from 'drizzle-orm';
import { requireAuth } from '@/lib/auth';
import * as schema from '@/lib/db/schema';
import { getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
import { getPlayer, updatePlayerEconomy } from '@/lib/db/localPlayer';
import { persist, type GameDb } from '@/lib/db/client';
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm, initialBearingDeg } from '@/lib/geo';
@@ -35,6 +35,7 @@ import { HUB_ICAO } from './seed';
// 404 — unknown entity id
// 409 — valid ids but the current state forbids the action (wrong package/plane
// status, plane not at origin, no payload room, insufficient fuel/funds, last plane)
// 403 — v1.2.0 (ISS-109): the session player does not own the plane
// 400 — bad input that isn't an existence problem (unknown airport/model)
export class GameActionError extends Error {
@@ -54,13 +55,18 @@ async function airportByIcao(icao: string): Promise<{ icao: string; lat: number;
return { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon };
}
// v1.2.0 (ISS-109): every plane action requires the session player to own the plane — 403 otherwise.
function assertPlaneOwner(plane: schema.PlaneRow, playerId: string): void {
if (plane.ownerId !== playerId) throw new GameActionError(403, 'not your plane');
}
// --- Load (POST /api/packages/:id/load {planeId}) ---
// open → loaded, bound to the plane. The plane must be GROUNDED at the package's
// origin with enough payload room (v0.27.0: used kg = sum of weightKg over packages
// 'loaded' or 'expired' on it — expired cargo keeps occupying its space until
// delivered/abandoned).
export async function loadPackage(inst: GameDb, packageId: string, planeId: string): Promise<void> {
export async function loadPackage(inst: GameDb, packageId: string, planeId: string, playerId: string): Promise<void> {
const [pkgRows, planeRows] = await Promise.all([
inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)),
inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)),
@@ -72,6 +78,7 @@ export async function loadPackage(inst: GameDb, packageId: string, planeId: stri
}
const plane = planeRows[0];
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes
if (plane.status !== 'available') {
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can load cargo`);
}
@@ -111,10 +118,11 @@ export async function loadPackage(inst: GameDb, packageId: string, planeId: stri
// doesn't fit even a full tank still 409s (insufficient fuel), and an unaffordable top-up 409s
// with nothing mutated. v0.45.0 (ISS-089): a broken plane (health ≤ 0) cannot fly at all — it
// 409s before any refuel math, so fuel and cash stay untouched.
export async function flyPlane(inst: GameDb, planeId: string, destIcao: string): Promise<void> {
export async function flyPlane(inst: GameDb, planeId: string, destIcao: string, playerId: string): Promise<void> {
const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
const plane = planeRows[0];
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes
if (plane.status !== 'available') {
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can fly`);
}
@@ -142,7 +150,8 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
// any mutation, so a 409 leaves fuel and cash untouched.
const target = Math.min(plane.maxFuel, Math.max(plane.fuel, requiredL * 1.5));
const liters = Math.max(0, target - plane.fuel);
const player = await getLocalPlayer(inst);
// v1.2.0 (ISS-109): the acting player's own economy pays for the top-up (they own the plane).
const player = await getPlayer(inst, playerId);
const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(player.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100;
if (cost > 0 && player.cash < cost) {
throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(player.cash)}`);
@@ -174,7 +183,7 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
})
.where(eq(schema.planes.id, planeId));
if (cost > 0) {
await updateLocalPlayerEconomy(inst, { cash: Math.round((player.cash - cost) * 100) / 100 });
await updatePlayerEconomy(inst, playerId, { cash: Math.round((player.cash - cost) * 100) / 100 });
}
await persist(inst);
}
@@ -186,10 +195,11 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
// active leg still starts via flyPlane, and queued legs chain on landing when fuel
// covers them (lib/sim/engine.ts deliverPackages). Validation: known plane, ICAO
// in the airport dataset.
export async function queueLeg(inst: GameDb, planeId: string, destIcao: string): Promise<void> {
export async function queueLeg(inst: GameDb, planeId: string, destIcao: string, playerId: string): Promise<void> {
const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
const plane = planeRows[0];
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes
const fc = await loadAirportsGeoJson();
if (!fc.features.some((f) => f.properties.icao === destIcao)) {
@@ -216,10 +226,11 @@ export async function queueLeg(inst: GameDb, planeId: string, destIcao: string):
// Remove a NOT-yet-started leg from the queue by index. Only queued legs are
// cancellable — the active in-flight leg (destIcao) is never affected. Validation:
// known plane, integer index within [0, queue.length).
export async function cancelQueuedLeg(inst: GameDb, planeId: string, index: number): Promise<void> {
export async function cancelQueuedLeg(inst: GameDb, planeId: string, index: number, playerId: string): Promise<void> {
const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
const plane = planeRows[0];
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes
const queue = parseIcaoQueue(plane.queue);
if (index < 0 || index >= queue.length) {
@@ -236,7 +247,7 @@ export async function cancelQueuedLeg(inst: GameDb, planeId: string, index: numb
// scaling with time pressure (15% at creation → 60% at the deadline, see
// lib/game/penalty.ts); an already-expired package abandons for free. Cash never
// goes below €0. The slot is freed immediately.
export async function abandonPackage(inst: GameDb, packageId: string): Promise<{ penalty: number }> {
export async function abandonPackage(inst: GameDb, packageId: string, playerId: string): Promise<{ penalty: number }> {
const pkgRows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId));
const pkg = pkgRows[0];
if (!pkg) throw new GameActionError(404, `package ${packageId} not found`);
@@ -248,6 +259,7 @@ export async function abandonPackage(inst: GameDb, packageId: string): Promise<{
? (await inst.db.select().from(schema.planes).where(eq(schema.planes.id, pkg.planeId)))[0]
: undefined;
if (!plane) throw new GameActionError(409, `package ${packageId} is not bound to a plane`);
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes (via the bound plane)
if (plane.status !== 'available') {
throw new GameActionError(409, `the plane is in flight — packages can only be abandoned while it is grounded`);
}
@@ -256,8 +268,9 @@ export async function abandonPackage(inst: GameDb, packageId: string): Promise<{
if (!sim) throw new Error('sim_state row missing');
const penalty = abandonPenalty(pkg, sim.clockMs);
const player = await getLocalPlayer(inst);
await updateLocalPlayerEconomy(inst, { cash: Math.max(0, Math.round((player.cash - penalty) * 100) / 100) });
// v1.2.0 (ISS-109): the acting player's own economy pays the penalty.
const player = await getPlayer(inst, playerId);
await updatePlayerEconomy(inst, playerId, { cash: Math.max(0, Math.round((player.cash - penalty) * 100) / 100) });
await inst.db.update(schema.packages).set({ status: 'abandoned', planeId: null }).where(eq(schema.packages.id, packageId));
await persist(inst);
return { penalty };
@@ -268,11 +281,12 @@ export async function abandonPackage(inst: GameDb, packageId: string): Promise<{
// Buy a catalog model at its reputation-scaled price (v0.38.0, ISS-072 — discount grows with
// reputation, exactly full price at the starting value); it arrives 'available' with a full tank
// at the home base (EDDF).
export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId: string }> {
export async function buyPlane(inst: GameDb, model: string, playerId: string): Promise<{ planeId: string }> {
const type = PLANE_TYPES[model];
if (!type) throw new GameActionError(400, `unknown plane model "${model}"`);
const player = await getLocalPlayer(inst);
// v1.2.0 (ISS-109): the buyer's own economy pays and becomes the owner of the delivered plane.
const player = await getPlayer(inst, playerId);
// v0.38.0 (ISS-072): the EFFECTIVE cost drives both the funds check and the deduction.
const cost = Math.round(type.price * (1 - repRate(player.reputation, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)) * 100) / 100;
if (player.cash < cost) {
@@ -286,6 +300,7 @@ export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId:
const planeId = randomUUID();
await inst.db.insert(schema.planes).values({
id: planeId,
ownerId: playerId, // v1.2.0 (ISS-109): the buyer owns what they buy
model,
status: 'available',
fuel: type.maxFuel, // full tank on delivery
@@ -297,7 +312,7 @@ export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId:
departureSimTime: null,
health: 100, // new planes arrive at full health (v0.39.0, ISS-069)
});
await updateLocalPlayerEconomy(inst, { cash: Math.round((player.cash - cost) * 100) / 100 });
await updatePlayerEconomy(inst, playerId, { cash: Math.round((player.cash - cost) * 100) / 100 });
await persist(inst);
return { planeId };
}
@@ -306,10 +321,11 @@ export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId:
// Sell a grounded plane for SELL_FRACTION of its catalog price. The last
// remaining plane can never be sold — the company needs at least one aircraft.
export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout: number }> {
export async function sellPlane(inst: GameDb, planeId: string, playerId: string): Promise<{ payout: number }> {
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
const plane = rows[0];
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes
if (plane.status !== 'available') {
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be sold`);
}
@@ -328,16 +344,18 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
throw new GameActionError(409, 'the plane has queued legs — cancel queued legs first');
}
const all = await inst.db.select().from(schema.planes);
if (all.length <= 1) throw new GameActionError(409, 'cannot sell the last remaining plane');
// v1.2.0 (ISS-109): the guard is PER OWNER — you can't sell your last plane even if other
// players' planes exist in the world, and selling one of two of yours is fine.
const owned = await inst.db.select({ id: schema.planes.id }).from(schema.planes).where(eq(schema.planes.ownerId, playerId));
if (owned.length <= 1) throw new GameActionError(409, 'cannot sell the last remaining plane');
const type = PLANE_TYPES[plane.model];
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
const payout = Math.round(type.price * SELL_FRACTION * 100) / 100;
await inst.db.delete(schema.planes).where(eq(schema.planes.id, planeId));
const player = await getLocalPlayer(inst);
await updateLocalPlayerEconomy(inst, { cash: Math.round((player.cash + payout) * 100) / 100 });
const player = await getPlayer(inst, playerId);
await updatePlayerEconomy(inst, playerId, { cash: Math.round((player.cash + payout) * 100) / 100 });
await persist(inst);
return { payout };
}
@@ -350,10 +368,11 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
// and flips the plane to 'available' exactly when it reaches full health (the gate). Only GROUNDED
// planes can be repaired (409 otherwise); an already-full-health repair is a free no-op. 404 unknown
// plane; 409 not grounded / insufficient funds.
export async function repairPlane(inst: GameDb, planeId: string): Promise<{ cost: number }> {
export async function repairPlane(inst: GameDb, planeId: string, playerId: string): Promise<{ cost: number }> {
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
const plane = rows[0];
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
assertPlaneOwner(plane, playerId); // v1.2.0 (ISS-109): 403 on foreign planes
if (plane.status !== 'available') {
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be repaired`);
}
@@ -362,7 +381,8 @@ export async function repairPlane(inst: GameDb, planeId: string): Promise<{ cost
const sim = (await inst.db.select().from(schema.simState))[0];
if (!sim) throw new Error('sim_state row missing');
const player = await getLocalPlayer(inst);
// v1.2.0 (ISS-109): the owner's own economy pays for the repair.
const player = await getPlayer(inst, playerId);
if (player.cash < REPAIR_COST) {
throw new GameActionError(409, `insufficient funds: repair costs ${REPAIR_COST}, you have ${Math.round(player.cash)}`);
}
@@ -371,7 +391,7 @@ export async function repairPlane(inst: GameDb, planeId: string): Promise<{ cost
await inst.db.update(schema.planes)
.set({ status: 'repairing', repairUntilSimTime: sim.clockMs + REPAIR_DURATION_MS })
.where(eq(schema.planes.id, planeId));
await updateLocalPlayerEconomy(inst, { cash: Math.round((player.cash - REPAIR_COST) * 100) / 100 });
await updatePlayerEconomy(inst, playerId, { cash: Math.round((player.cash - REPAIR_COST) * 100) / 100 });
await persist(inst);
return { cost: REPAIR_COST };
}
@@ -384,11 +404,13 @@ export async function repairPlane(inst: GameDb, planeId: string): Promise<{ cost
// its own status + {error}; anything unexpected is a 500.
// v1.1.0 (ISS-107): every action route funnels through here, so the requireAuth guard lives in ONE
// place — 401 before any game mutation when the X-Flight-Session header is missing/unknown/expired.
export async function performAction(req: Request, fn: (inst: GameDb) => Promise<unknown>): Promise<Response> {
// v1.2.0 (ISS-109): fn receives the session player's id as its second argument and threads it into
// the action for ownership checks + per-player economy — routes stay a pure pass-through.
export async function performAction(req: Request, fn: (inst: GameDb, playerId: string) => Promise<unknown>): Promise<Response> {
const auth = await requireAuth(req);
if (!auth.ok) return auth.response;
try {
await runAction(fn);
await runAction((inst) => fn(inst, auth.player.id));
return Response.json(await getStateSnapshot());
} catch (err) {
if (err instanceof GameActionError) {
+18 -11
View File
@@ -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.
+9 -8
View File
@@ -185,16 +185,17 @@ describe('OpenAPI spec', () => {
expect(ok.content['application/json'].schema.$ref).toBe('#/components/schemas/StateSnapshot');
}
// Error shapes per endpoint.
// Error shapes per endpoint. v1.2.0 (ISS-109): every plane-targeted action also documents 403
// (the session player does not own the plane); buy-plane has no plane target, so it gains none.
const errorCodes = (p: string) => Object.keys(post(p).responses).filter((k) => k !== '200').sort();
expect(errorCodes('/api/packages/{id}/load')).toEqual(['400', '404', '409']);
expect(errorCodes('/api/packages/{id}/abandon')).toEqual(['404', '409']);
expect(errorCodes('/api/planes/{planeId}/fly')).toEqual(['400', '404', '409']);
expect(errorCodes('/api/planes/{planeId}/queue')).toEqual(['400', '404', '409']); // v0.34.0 (ISS-065) — 409 on duplicate leg / active-leg re-queue
expect(errorCodes('/api/planes/{planeId}/queue/cancel')).toEqual(['400', '404']); // v0.25.0
expect(errorCodes('/api/packages/{id}/load')).toEqual(['400', '403', '404', '409']);
expect(errorCodes('/api/packages/{id}/abandon')).toEqual(['403', '404', '409']);
expect(errorCodes('/api/planes/{planeId}/fly')).toEqual(['400', '403', '404', '409']);
expect(errorCodes('/api/planes/{planeId}/queue')).toEqual(['400', '403', '404', '409']); // v0.34.0 (ISS-065) — 409 on duplicate leg / active-leg re-queue
expect(errorCodes('/api/planes/{planeId}/queue/cancel')).toEqual(['400', '403', '404']); // v0.25.0
expect(errorCodes('/api/market/buy-plane')).toEqual(['400', '409']);
expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '404', '409']);
expect(errorCodes('/api/planes/{planeId}/repair')).toEqual(['404', '409']); // v0.39.0 (ISS-069) — no body, so no 400
expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '403', '404', '409']);
expect(errorCodes('/api/planes/{planeId}/repair')).toEqual(['403', '404', '409']); // v0.39.0 (ISS-069) — no body, so no 400
// Request bodies: load/fly/buy/sell take their dedicated schemas; abandon and repair take none.
expect(post('/api/packages/{id}/load').requestBody?.content['application/json'].schema.$ref).toBe(
+28
View File
@@ -110,6 +110,10 @@ export const openApiSpec = {
description: 'Invalid body (not an object, unknown field, or missing/non-string planeId)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
403: {
description: 'The session player does not own this plane (v1.2.0, ISS-109)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
404: {
description: 'Unknown package id or plane id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
@@ -136,6 +140,10 @@ export const openApiSpec = {
description: 'Abandoned — full state snapshot (package "abandoned", cash reduced by the penalty)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
},
403: {
description: 'The session player does not own the bound plane (v1.2.0, ISS-109)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
404: {
description: 'Unknown package id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
@@ -174,6 +182,10 @@ export const openApiSpec = {
'Invalid body (not an object, unknown field, or missing/non-string destIcao) or the ICAO is not in the airport dataset',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
403: {
description: 'The session player does not own this plane (v1.2.0, ISS-109)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
404: {
description: 'Unknown plane id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
@@ -213,6 +225,10 @@ export const openApiSpec = {
'Invalid body (not an object, unknown field, or missing/non-string destIcao) or the ICAO is not in the airport dataset',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
403: {
description: 'The session player does not own this plane (v1.2.0, ISS-109)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
404: {
description: 'Unknown plane id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
@@ -252,6 +268,10 @@ export const openApiSpec = {
'Invalid body (not an object, unknown field, or non-integer index) or the index is outside [0, queue.length)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
403: {
description: 'The session player does not own this plane (v1.2.0, ISS-109)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
404: {
description: 'Unknown plane id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
@@ -312,6 +332,10 @@ export const openApiSpec = {
description: 'Invalid body (not an object, unknown field, or missing/non-string planeId)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
403: {
description: 'The session player does not own this plane (v1.2.0, ISS-109)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
404: {
description: 'Unknown plane id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
@@ -337,6 +361,10 @@ export const openApiSpec = {
description: 'Repair started (or free no-op) — full state snapshot (status "repairing" with the gate set, health unchanged until the engine\'s tick heals it back to 100 over the duration, cash reduced by the flat cost)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
},
403: {
description: 'The session player does not own this plane (v1.2.0, ISS-109)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
404: {
description: 'Unknown plane id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
+2 -2
View File
@@ -783,7 +783,7 @@ describe('spawnPoolPackages (v0.65.0 per-airport pools)', () => {
.set({ status: 'flying', destIcao: 'LEMD', departureSimTime: T0, queue: JSON.stringify(['EHAM']) })
.where(eq(schema.planes.id, 'plane-starter'));
await inst.db.insert(schema.planes).values({
id: 'p-2', model: 'atr72', status: 'available', fuel: 100, maxFuel: 6300, locationIcao: 'LEMD', destIcao: null, lat: 40.4, lon: -3.6, departureSimTime: null,
id: 'p-2', ownerId: 'local', model: 'atr72', status: 'available', fuel: 100, maxFuel: 6300, locationIcao: 'LEMD', destIcao: null, lat: 40.4, lon: -3.6, departureSimTime: null,
});
const n = await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0);
@@ -828,7 +828,7 @@ describe('spawnPoolPackages (v0.65.0 per-airport pools)', () => {
// grounded — pools at EDDF (grounded) and LEMD (flying destination). Arrival is hours out, so it
// never lands here.
await inst.db.insert(schema.planes).values({
id: 'p-air', model: 'atr72', status: 'flying', fuel: 6300, maxFuel: 6300,
id: 'p-air', ownerId: 'local', model: 'atr72', status: 'flying', fuel: 6300, maxFuel: 6300,
locationIcao: 'EDDF', destIcao: 'LEMD', lat: 50.03, lon: 8.57, departureSimTime: STARTING_CLOCK_MS,
});
vi.setSystemTime(T0 + 1_000); // one real second → advanceMs > 0 at the seeded scale
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "flight-ops",
"version": "1.1.0",
"version": "1.2.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",