From ec26f9f366c45c33b28df8725c4cc4858f167861 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Sat, 26 Sep 2026 16:27:00 +0000 Subject: [PATCH] v1.2.0: planes.owner_id + ownership checks + per-player onboarding (ISS-108, ISS-109) --- CHANGELOG.md | 7 + app/api/market/buy-plane/route.ts | 2 +- app/api/market/sell-plane/route.test.ts | 2 +- app/api/market/sell-plane/route.ts | 2 +- app/api/packages/[id]/abandon/route.ts | 2 +- app/api/packages/[id]/load/route.ts | 2 +- app/api/planes/[planeId]/fly/route.ts | 2 +- .../planes/[planeId]/queue/cancel/route.ts | 2 +- app/api/planes/[planeId]/queue/route.ts | 2 +- app/api/planes/[planeId]/repair/route.ts | 2 +- app/api/register/route.test.ts | 5 + app/api/register/route.ts | 13 +- .../test/mint-player-session/route.test.ts | 34 ++- app/api/test/mint-player-session/route.ts | 18 +- lib/db/client.test.ts | 63 +++++ lib/db/client.ts | 12 +- lib/db/localPlayer.ts | 17 ++ lib/db/schema.ts | 4 + lib/game/actions.test.ts | 245 ++++++++++++------ lib/game/actions.ts | 68 +++-- lib/game/seed.ts | 29 ++- lib/openapi.test.ts | 17 +- lib/openapi.ts | 28 ++ lib/sim/engine.test.ts | 4 +- package.json | 2 +- 25 files changed, 436 insertions(+), 148 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fa56d2..6135163 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/app/api/market/buy-plane/route.ts b/app/api/market/buy-plane/route.ts index 45d78f5..b8472c4 100644 --- a/app/api/market/buy-plane/route.ts +++ b/app/api/market/buy-plane/route.ts @@ -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)); } diff --git a/app/api/market/sell-plane/route.test.ts b/app/api/market/sell-plane/route.test.ts index 5ddc456..6535c99 100644 --- a/app/api/market/sell-plane/route.test.ts +++ b/app/api/market/sell-plane/route.test.ts @@ -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')); diff --git a/app/api/market/sell-plane/route.ts b/app/api/market/sell-plane/route.ts index df1c3ca..640a0f5 100644 --- a/app/api/market/sell-plane/route.ts +++ b/app/api/market/sell-plane/route.ts @@ -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)); } diff --git a/app/api/packages/[id]/abandon/route.ts b/app/api/packages/[id]/abandon/route.ts index cfccaab..6713646 100644 --- a/app/api/packages/[id]/abandon/route.ts +++ b/app/api/packages/[id]/abandon/route.ts @@ -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)); } diff --git a/app/api/packages/[id]/load/route.ts b/app/api/packages/[id]/load/route.ts index c4cbfd9..3f0e2c1 100644 --- a/app/api/packages/[id]/load/route.ts +++ b/app/api/packages/[id]/load/route.ts @@ -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)); } diff --git a/app/api/planes/[planeId]/fly/route.ts b/app/api/planes/[planeId]/fly/route.ts index cfd0926..0d9cb06 100644 --- a/app/api/planes/[planeId]/fly/route.ts +++ b/app/api/planes/[planeId]/fly/route.ts @@ -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)); } diff --git a/app/api/planes/[planeId]/queue/cancel/route.ts b/app/api/planes/[planeId]/queue/cancel/route.ts index cd9b454..91bf136 100644 --- a/app/api/planes/[planeId]/queue/cancel/route.ts +++ b/app/api/planes/[planeId]/queue/cancel/route.ts @@ -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)); } diff --git a/app/api/planes/[planeId]/queue/route.ts b/app/api/planes/[planeId]/queue/route.ts index e3e9099..7f42c1c 100644 --- a/app/api/planes/[planeId]/queue/route.ts +++ b/app/api/planes/[planeId]/queue/route.ts @@ -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)); } diff --git a/app/api/planes/[planeId]/repair/route.ts b/app/api/planes/[planeId]/repair/route.ts index 5c9edbf..531c76d 100644 --- a/app/api/planes/[planeId]/repair/route.ts +++ b/app/api/planes/[planeId]/repair/route.ts @@ -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)); } diff --git a/app/api/register/route.test.ts b/app/api/register/route.test.ts index 130fe95..6a96775 100644 --- a/app/api/register/route.test.ts +++ b/app/api/register/route.test.ts @@ -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])); diff --git a/app/api/register/route.ts b/app/api/register/route.ts index d673f50..7e88c68 100644 --- a/app/api/register/route.ts +++ b/app/api/register/route.ts @@ -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); diff --git a/app/api/test/mint-player-session/route.test.ts b/app/api/test/mint-player-session/route.test.ts index 15d1b63..25ae5d6 100644 --- a/app/api/test/mint-player-session/route.test.ts +++ b/app/api/test/mint-player-session/route.test.ts @@ -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 () => { diff --git a/app/api/test/mint-player-session/route.ts b/app/api/test/mint-player-session/route.ts index db34b4e..9735687 100644 --- a/app/api/test/mint-player-session/route.ts +++ b/app/api/test/mint-player-session/route.ts @@ -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 diff --git a/lib/db/client.test.ts b/lib/db/client.test.ts index cb843ac..d6cb0f5 100644 --- a/lib/db/client.test.ts +++ b/lib/db/client.test.ts @@ -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']); + }); }); diff --git a/lib/db/client.ts b/lib/db/client.ts index 92fd944..f7559e7 100644 --- a/lib/db/client.ts +++ b/lib/db/client.ts @@ -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 { // 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 }); diff --git a/lib/db/localPlayer.ts b/lib/db/localPlayer.ts index 6f68743..cbb8e2c 100644 --- a/lib/db/localPlayer.ts +++ b/lib/db/localPlayer.ts @@ -21,3 +21,20 @@ export async function updateLocalPlayerEconomy( ): Promise { 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 { + 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 { + await inst.db.update(schema.players).set(patch).where(eq(schema.players.id, id)); +} diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 8221f03..a4dab65 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -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'. diff --git a/lib/game/actions.test.ts b/lib/game/actions.test.ts index 64ca2ea..7dbb4e4 100644 --- a/lib/game/actions.test.ts +++ b/lib/game/actions.test.ts @@ -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 { + 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 }); }); diff --git a/lib/game/actions.ts b/lib/game/actions.ts index 37d8e7b..1a8d9a6 100644 --- a/lib/game/actions.ts +++ b/lib/game/actions.ts @@ -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 { +export async function loadPackage(inst: GameDb, packageId: string, planeId: string, playerId: string): Promise { 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 { +export async function flyPlane(inst: GameDb, planeId: string, destIcao: string, playerId: string): Promise { 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 { +export async function queueLeg(inst: GameDb, planeId: string, destIcao: string, playerId: string): Promise { 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 { +export async function cancelQueuedLeg(inst: GameDb, planeId: string, index: number, playerId: string): Promise { 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): Promise { +// 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): Promise { 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) { diff --git a/lib/game/seed.ts b/lib/game/seed.ts index e8a31b1..c85711c 100644 --- a/lib/game/seed.ts +++ b/lib/game/seed.ts @@ -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. diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts index 22f7683..e6efe6f 100644 --- a/lib/openapi.test.ts +++ b/lib/openapi.test.ts @@ -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( diff --git a/lib/openapi.ts b/lib/openapi.ts index 2d78798..6d78cb9 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -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' } } }, diff --git a/lib/sim/engine.test.ts b/lib/sim/engine.test.ts index 5483366..bf3f2ed 100644 --- a/lib/sim/engine.test.ts +++ b/lib/sim/engine.test.ts @@ -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 diff --git a/package.json b/package.json index f01707a..714e817 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "1.1.0", + "version": "1.2.0", "private": true, "scripts": { "dev": "next dev -p 3000",