Merge pull request 'v1.2.0: planes.owner_id + ownership checks + per-player onboarding (ISS-108, ISS-109)' (#153) from feat/multiplayer-ownership into master
CI / build + unit + e2e (push) Successful in 13m22s
CI / build + unit + e2e (push) Successful in 13m22s
This commit was merged in pull request #153.
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# 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
|
## 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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -27,5 +27,5 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const model = input.model;
|
const model = input.model;
|
||||||
return performAction(req, (inst) => buyPlane(inst, model));
|
return performAction(req, (inst, playerId) => buyPlane(inst, model, playerId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ describe('POST /api/market/sell-plane', () => {
|
|||||||
const { db } = await getGameDb();
|
const { db } = await getGameDb();
|
||||||
// A second, grounded plane so "flying" is the reason for rejection.
|
// A second, grounded plane so "flying" is the reason for rejection.
|
||||||
await db.insert(schema.planes).values({
|
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,
|
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'));
|
await db.update(schema.planes).set({ status: 'flying' }).where(eq(schema.planes.id, 'plane-extra'));
|
||||||
|
|||||||
@@ -27,5 +27,5 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const planeId = input.planeId;
|
const planeId = input.planeId;
|
||||||
return performAction(req, (inst) => sellPlane(inst, planeId));
|
return performAction(req, (inst, playerId) => sellPlane(inst, planeId, playerId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,5 +9,5 @@ import { abandonPackage, performAction } from '@/lib/game/actions';
|
|||||||
|
|
||||||
export async function POST(req: Request, ctx: { params: Promise<{ id: string }> }) {
|
export async function POST(req: Request, ctx: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await ctx.params;
|
const { id } = await ctx.params;
|
||||||
return performAction(req, (inst) => abandonPackage(inst, id));
|
return performAction(req, (inst, playerId) => abandonPackage(inst, id, playerId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,5 +31,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ id: string }>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const planeId = input.planeId;
|
const planeId = input.planeId;
|
||||||
return performAction(req, (inst) => loadPackage(inst, id, planeId));
|
return performAction(req, (inst, playerId) => loadPackage(inst, id, planeId, playerId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,5 +31,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ planeId: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
const destIcao = input.destIcao;
|
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;
|
const index = input.index as number;
|
||||||
return performAction(req, (inst) => cancelQueuedLeg(inst, planeId, index));
|
return performAction(req, (inst, playerId) => cancelQueuedLeg(inst, planeId, index, playerId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,5 +31,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ planeId: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
const destIcao = input.destIcao;
|
const destIcao = input.destIcao;
|
||||||
return performAction(req, (inst) => queueLeg(inst, planeId, destIcao));
|
return performAction(req, (inst, playerId) => queueLeg(inst, planeId, destIcao, playerId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,5 +8,5 @@ import { performAction, repairPlane } from '@/lib/game/actions';
|
|||||||
|
|
||||||
export async function POST(req: Request, ctx: { params: Promise<{ planeId: string }> }) {
|
export async function POST(req: Request, ctx: { params: Promise<{ planeId: string }> }) {
|
||||||
const { planeId } = await ctx.params;
|
const { planeId } = await ctx.params;
|
||||||
return performAction(req, (inst) => repairPlane(inst, planeId));
|
return performAction(req, (inst, playerId) => repairPlane(inst, planeId, playerId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ describe('POST /api/register', () => {
|
|||||||
expect(Number(playerRow[2])).toBe(STARTING_CASH);
|
expect(Number(playerRow[2])).toBe(STARTING_CASH);
|
||||||
expect(Number(playerRow[3])).toBe(STARTING_REPUTATION);
|
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.
|
// 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];
|
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]));
|
expect(String(sess[0])).toBe(String(userRow[0]));
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import * as schema from '@/lib/db/schema';
|
import * as schema from '@/lib/db/schema';
|
||||||
import { getGameDb, persist } from '@/lib/db/client';
|
import { getGameDb, persist } from '@/lib/db/client';
|
||||||
import { createSession, hashPassword } from '@/lib/auth';
|
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
|
// v1.1.0 (ISS-107) — POST /api/register
|
||||||
// body { username: string, password: string } (both required; unknown fields 400).
|
// body { username: string, password: string } (both required; unknown fields 400).
|
||||||
@@ -64,6 +65,16 @@ export async function POST(req: Request) {
|
|||||||
cash: STARTING_CASH,
|
cash: STARTING_CASH,
|
||||||
reputation: STARTING_REPUTATION,
|
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);
|
await persist(inst);
|
||||||
|
|
||||||
const sessionId = await createSession(inst, id);
|
const sessionId = await createSession(inst, id);
|
||||||
|
|||||||
@@ -36,32 +36,52 @@ describe('POST /api/test/mint-player-session', () => {
|
|||||||
expect(res.status).toBe(404);
|
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');
|
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);
|
expect(res.status).toBe(200);
|
||||||
const body = (await res.json()) as { sessionId: string };
|
const body = (await res.json()) as { sessionId: string };
|
||||||
expect(body.sessionId).toMatch(/^[0-9a-f-]{36}$/);
|
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 inst = await getGameDb();
|
||||||
const userRow = inst.raw.exec("SELECT id FROM users WHERE username = 'e2e-tester'")[0].values[0];
|
const userRow = inst.raw.exec("SELECT id FROM users WHERE username = 'e2e-tester'")[0].values[0];
|
||||||
expect(userRow.length).toBe(1);
|
expect(userRow.length).toBe(1);
|
||||||
const playerRow = inst.raw.exec("SELECT id, cash FROM players WHERE username = 'e2e-tester'")[0].values[0];
|
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
|
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.
|
// 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];
|
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]));
|
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');
|
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]);
|
const count = async (sql: string) => Number((await getGameDb()).raw.exec(sql)[0].values[0][0]);
|
||||||
expect(await count()).toBe(1);
|
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(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 () => {
|
it('rejects bad usernames and unknown fields with 400', async () => {
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import * as schema from '@/lib/db/schema';
|
import * as schema from '@/lib/db/schema';
|
||||||
import { getGameDb, persist } from '@/lib/db/client';
|
import { getGameDb, persist } from '@/lib/db/client';
|
||||||
import { createSession, hashPassword } from '@/lib/auth';
|
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
|
// 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
|
// 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.
|
// other deployment 404s here, like its /api/test/* siblings.
|
||||||
//
|
//
|
||||||
// POST /api/test/mint-player-session
|
// 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 }.
|
// 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
|
// 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.
|
// server-side); a fixed value keeps the row debuggable from the admin panel.
|
||||||
const TEST_PASSWORD = 'e2e-mint-password-42';
|
const TEST_PASSWORD = 'e2e-mint-password-42';
|
||||||
@@ -70,6 +74,14 @@ export async function POST(req: Request) {
|
|||||||
cash: STARTING_CASH,
|
cash: STARTING_CASH,
|
||||||
reputation: STARTING_REPUTATION,
|
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;
|
userId = id;
|
||||||
}
|
}
|
||||||
const sessionId = await createSession(inst, userId); // persists the rows + session together
|
const sessionId = await createSession(inst, userId); // persists the rows + session together
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import initSqlJs from 'sql.js';
|
import initSqlJs from 'sql.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
import { WORLD_TIME_SCALE } from '@/lib/worldClock';
|
import { WORLD_TIME_SCALE } from '@/lib/worldClock';
|
||||||
import { STARTING_CASH, STARTING_CLOCK_MS, STARTING_REPUTATION } from '@/lib/game/seed';
|
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;
|
let dir: string;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -350,4 +385,32 @@ describe('openGameDb', () => {
|
|||||||
expect(tables.has('account')).toBe(false);
|
expect(tables.has('account')).toBe(false);
|
||||||
expect(Number(second.raw.exec('SELECT COUNT(*) FROM players')[0].values[0][0])).toBe(1);
|
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
@@ -23,6 +23,9 @@ export interface GameDb {
|
|||||||
const DDL = `
|
const DDL = `
|
||||||
CREATE TABLE IF NOT EXISTS planes (
|
CREATE TABLE IF NOT EXISTS planes (
|
||||||
id TEXT PRIMARY KEY NOT NULL,
|
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,
|
model TEXT NOT NULL,
|
||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
fuel REAL 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('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');
|
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
|
// 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.
|
// SeedPlane.queue is a string[]; the column stores it as JSON text.
|
||||||
const { queue, ...planeCols } = data.plane;
|
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);
|
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.
|
// 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 });
|
await instance.db.insert(schema.players).values({ ...LOCAL_PLAYER, cash: data.account.cash, reputation: data.account.reputation });
|
||||||
|
|||||||
@@ -21,3 +21,20 @@ export async function updateLocalPlayerEconomy(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await inst.db.update(schema.players).set(patch).where(eq(schema.players.id, LOCAL_PLAYER.id));
|
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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import { integer, real, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
|||||||
|
|
||||||
export const planes = sqliteTable('planes', {
|
export const planes = sqliteTable('planes', {
|
||||||
id: text('id').primaryKey().notNull(),
|
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(),
|
model: text('model').notNull(),
|
||||||
// v0.39.0 (ISS-069): 'repairing' = grounded in the shop until repairUntilSimTime passes; the
|
// 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'.
|
// engine flips it back to 'available' on a tick. All status guards key off 'available'.
|
||||||
|
|||||||
+163
-82
@@ -5,7 +5,7 @@ import { eq } from 'drizzle-orm';
|
|||||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
import { openGameDb, type GameDb } from '@/lib/db/client';
|
import { openGameDb, type GameDb } from '@/lib/db/client';
|
||||||
import * as schema from '@/lib/db/schema';
|
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 { loadAirportsGeoJson } from '@/lib/airports';
|
||||||
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||||
import { weatherFor } from '@/lib/weather';
|
import { weatherFor } from '@/lib/weather';
|
||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
repRate,
|
repRate,
|
||||||
SELL_FRACTION,
|
SELL_FRACTION,
|
||||||
} from './catalog';
|
} from './catalog';
|
||||||
import { HUB_ICAO, STARTING_CLOCK_MS } from './seed';
|
import { HUB_ICAO, STARTING_CASH, STARTING_CLOCK_MS, STARTING_REPUTATION } from './seed';
|
||||||
import {
|
import {
|
||||||
abandonPackage,
|
abandonPackage,
|
||||||
buyPlane,
|
buyPlane,
|
||||||
@@ -94,7 +94,7 @@ describe('loadPackage', () => {
|
|||||||
const inst = await freshDb('load-ok.db');
|
const inst = await freshDb('load-ok.db');
|
||||||
await insertOpenPackage(inst);
|
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];
|
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-open')))[0];
|
||||||
expect(row.status).toBe('loaded');
|
expect(row.status).toBe('loaded');
|
||||||
@@ -107,19 +107,19 @@ describe('loadPackage', () => {
|
|||||||
const inst = await freshDb(`load-${status}.db`);
|
const inst = await freshDb(`load-${status}.db`);
|
||||||
await insertOpenPackage(inst, 'p-x', { status });
|
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 () => {
|
it('rejects an unknown package id with 404', async () => {
|
||||||
const inst = await freshDb('load-punknown.db');
|
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 () => {
|
it('rejects an unknown plane id with 404', async () => {
|
||||||
const inst = await freshDb('load-ghost.db');
|
const inst = await freshDb('load-ghost.db');
|
||||||
await insertOpenPackage(inst);
|
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 () => {
|
it('rejects a flying plane with 409', async () => {
|
||||||
@@ -129,7 +129,7 @@ describe('loadPackage', () => {
|
|||||||
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
.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 () => {
|
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 insertOpenPackage(inst); // originates at EDDF
|
||||||
await inst.db.update(schema.planes).set({ locationIcao: 'LEMD' }).where(eq(schema.planes.id, 'plane-starter'));
|
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 () => {
|
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 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 () => {
|
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-loaded-half', { status: 'loaded', planeId: 'plane-starter', weightKg: half });
|
||||||
await insertOpenPackage(inst, 'p-expired-slot', { status: 'expired', 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 () => {
|
it('sets flying + departure at current clock + dest_icao + origin coords', async () => {
|
||||||
const inst = await freshDb('fly-ok.db');
|
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];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(plane.status).toBe('flying');
|
expect(plane.status).toBe('flying');
|
||||||
@@ -188,17 +188,17 @@ describe('flyPlane', () => {
|
|||||||
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
.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 () => {
|
it('rejects an unknown plane id with 404', async () => {
|
||||||
const inst = await freshDb('fly-ghost.db');
|
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 () => {
|
it('rejects an ICAO not in the airport dataset with 400', async () => {
|
||||||
const inst = await freshDb('fly-unknownicao.db');
|
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 () => {
|
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'));
|
await inst.db.update(schema.planes).set({ health: 0 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
const cashBefore = (await getLocalPlayer(inst)).cash;
|
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];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(plane.status).toBe('available'); // still grounded…
|
expect(plane.status).toBe('available'); // still grounded…
|
||||||
@@ -219,7 +219,7 @@ describe('flyPlane', () => {
|
|||||||
const inst = await freshDb('fly-worn.db'); // EDDF → LEMD
|
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 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];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(plane.status).toBe('flying');
|
expect(plane.status).toBe('flying');
|
||||||
@@ -253,7 +253,7 @@ describe('flyPlane', () => {
|
|||||||
await updateLocalPlayerEconomy(inst, { reputation: 30 });
|
await updateLocalPlayerEconomy(inst, { reputation: 30 });
|
||||||
const cashBefore = (await getLocalPlayer(inst)).cash;
|
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);
|
const rate = repRate(30, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
|
||||||
expect(rate).toBeGreaterThan(0); // sanity pin on the formula input
|
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
|
expect(required * 1.5).toBeLessThan(PLANE_TYPES.atr72.maxFuel); // sanity: the target stays below a full tank
|
||||||
const cashBefore = (await getLocalPlayer(inst)).cash;
|
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];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(plane.status).toBe('flying');
|
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 inst.db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
await updateLocalPlayerEconomy(inst, { cash: 0 });
|
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];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(plane.status).toBe('available'); // still grounded…
|
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.
|
// 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 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) ---
|
// --- 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 () => {
|
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');
|
const inst = await freshDb('fly-consume.db');
|
||||||
await queueLeg(inst, 'plane-starter', 'LEMD');
|
await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
|
||||||
await queueLeg(inst, 'plane-starter', 'EHAM');
|
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];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(plane.status).toBe('flying');
|
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 () => {
|
it('keeps the queue intact when skipping ahead to a non-first queued leg (explicit override)', async () => {
|
||||||
const inst = await freshDb('fly-skipahead.db');
|
const inst = await freshDb('fly-skipahead.db');
|
||||||
await queueLeg(inst, 'plane-starter', 'LEMD');
|
await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
|
||||||
await queueLeg(inst, 'plane-starter', 'EHAM');
|
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];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(plane.status).toBe('flying');
|
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 () => {
|
it('appends legs to the queue in order and persists them as JSON', async () => {
|
||||||
const inst = await freshDb('qleg-ok.db');
|
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];
|
let plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(JSON.parse(plane.queue)).toEqual(['LEMD']);
|
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];
|
plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']); // order preserved
|
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' })
|
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
.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];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(plane.status).toBe('flying'); // still on its active leg…
|
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 () => {
|
it('rejects an unknown plane id with 404', async () => {
|
||||||
const inst = await freshDb('qleg-ghost.db');
|
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 () => {
|
it('rejects an ICAO not in the airport dataset with 400', async () => {
|
||||||
const inst = await freshDb('qleg-unknownicao.db');
|
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 () => {
|
it('rejects a destination already in the queue with 409 (ISS-065)', async () => {
|
||||||
const inst = await freshDb('qleg-dup.db');
|
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];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(JSON.parse(plane.queue)).toEqual(['LEMD']); // rejected leg not appended
|
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' })
|
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
.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];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(JSON.parse(plane.queue)).toEqual([]); // rejected leg not appended
|
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 () => {
|
it('still appends a different known airport with success (ISS-065 sanity)', async () => {
|
||||||
const inst = await freshDb('qleg-dupother.db');
|
const inst = await freshDb('qleg-dupother.db');
|
||||||
await queueLeg(inst, 'plane-starter', 'LEMD');
|
await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
|
||||||
await expect(queueLeg(inst, 'plane-starter', 'EHAM')).resolves.toBeUndefined();
|
await expect(queueLeg(inst, 'plane-starter', 'EHAM', 'local')).resolves.toBeUndefined();
|
||||||
|
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']);
|
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)', () => {
|
describe('cancelQueuedLeg (v0.25.0, ISS-043)', () => {
|
||||||
it('removes the leg at the given index and keeps the rest in order', async () => {
|
it('removes the leg at the given index and keeps the rest in order', async () => {
|
||||||
const inst = await freshDb('qcancel-ok.db');
|
const inst = await freshDb('qcancel-ok.db');
|
||||||
await queueLeg(inst, 'plane-starter', 'LEMD');
|
await queueLeg(inst, 'plane-starter', 'LEMD', 'local');
|
||||||
await queueLeg(inst, 'plane-starter', 'EHAM');
|
await queueLeg(inst, 'plane-starter', 'EHAM', 'local');
|
||||||
await queueLeg(inst, 'plane-starter', 'EDDV');
|
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];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EDDV']);
|
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 () => {
|
it('rejects an out-of-range index with 400', async () => {
|
||||||
const inst = await freshDb('qcancel-oob.db');
|
const inst = await freshDb('qcancel-oob.db');
|
||||||
await queueLeg(inst, 'plane-starter', 'LEMD'); // one leg → only index 0 is valid
|
await queueLeg(inst, 'plane-starter', 'LEMD', 'local'); // one leg → only index 0 is valid
|
||||||
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 1), 400);
|
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 1, 'local'), 400);
|
||||||
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', -1), 400);
|
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', -1, 'local'), 400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects cancelling from an empty queue with 400', async () => {
|
it('rejects cancelling from an empty queue with 400', async () => {
|
||||||
const inst = await freshDb('qcancel-empty.db'); // seeded starter has no queued legs
|
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 () => {
|
it('rejects an unknown plane id with 404', async () => {
|
||||||
const inst = await freshDb('qcancel-ghost.db');
|
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);
|
const inst = await freshDb(name);
|
||||||
// Always insert OPEN and load it through the real action…
|
// 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 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).
|
// …then flip to expired in place (what the tick would do once the deadline passes).
|
||||||
if (status === 'expired') {
|
if (status === 'expired') {
|
||||||
await inst.db.update(schema.packages).set({ status: 'expired' }).where(eq(schema.packages.id, 'p-ab'));
|
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 inst = await setupLoaded('ab-start.db');
|
||||||
const cashBefore = (await getLocalPlayer(inst)).cash;
|
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);
|
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];
|
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');
|
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
|
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);
|
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);
|
const inst = await setupLoaded(name);
|
||||||
await inst.db.update(schema.simState).set({ clockMs: clock });
|
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);
|
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 inst = await setupLoaded('ab-expired.db', 'expired');
|
||||||
const cashBefore = (await getLocalPlayer(inst)).cash;
|
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(penalty).toBe(0);
|
||||||
expect((await getLocalPlayer(inst)).cash).toBe(cashBefore);
|
expect((await getLocalPlayer(inst)).cash).toBe(cashBefore);
|
||||||
@@ -502,7 +502,7 @@ describe('abandonPackage', () => {
|
|||||||
const inst = await setupLoaded('ab-floor.db');
|
const inst = await setupLoaded('ab-floor.db');
|
||||||
await updateLocalPlayerEconomy(inst, { cash: 10 }); // penalty is 150 > 10
|
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(penalty).toBe(150);
|
||||||
expect((await getLocalPlayer(inst)).cash).toBe(0);
|
expect((await getLocalPlayer(inst)).cash).toBe(0);
|
||||||
@@ -514,19 +514,19 @@ describe('abandonPackage', () => {
|
|||||||
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
.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) => {
|
it.each(['open', 'delivered', 'abandoned'] as const)('rejects a %s package with 409', async (status) => {
|
||||||
const inst = await freshDb(`ab-${status}.db`);
|
const inst = await freshDb(`ab-${status}.db`);
|
||||||
await insertOpenPackage(inst, 'p-x', { status });
|
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 () => {
|
it('rejects an unknown id with 404', async () => {
|
||||||
const inst = await freshDb('ab-unknown.db');
|
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 });
|
await updateLocalPlayerEconomy(inst, { cash: 500_000 });
|
||||||
|
|
||||||
const before = (await inst.db.select().from(schema.planes)).length;
|
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);
|
const player = await getLocalPlayer(inst);
|
||||||
expect(player.cash).toBe(500_000 - PLANE_TYPES.atr72.price);
|
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 () => {
|
it('rejects an unknown catalog model with 400', async () => {
|
||||||
const inst = await freshDb('buy-unknown.db');
|
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 () => {
|
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
|
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 () => {
|
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 });
|
await updateLocalPlayerEconomy(inst, { cash: 500_000, reputation: 30 });
|
||||||
|
|
||||||
const before = (await inst.db.select().from(schema.planes)).length;
|
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);
|
const rate = repRate(30, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT);
|
||||||
expect(rate).toBeCloseTo(0.2, 12); // sanity pin on the formula input
|
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.
|
// 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 });
|
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
|
expect(planeId).toBeTruthy(); // the old full-price check would have rejected this
|
||||||
const player = await getLocalPlayer(inst);
|
const player = await getLocalPlayer(inst);
|
||||||
expect(player.cash).toBeCloseTo(170_000 - 160_000, 6);
|
expect(player.cash).toBeCloseTo(170_000 - 160_000, 6);
|
||||||
|
|
||||||
const inst2 = await freshDb('buy-boundary-poor.db'); // just below the discounted cost
|
const inst2 = await freshDb('buy-boundary-poor.db'); // just below the discounted cost
|
||||||
await updateLocalPlayerEconomy(inst2, { cash: 159_999.99, reputation: 30 });
|
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');
|
const inst = await freshDb('sell-ok.db');
|
||||||
// A second plane so the starter is not "the last remaining one".
|
// A second plane so the starter is not "the last remaining one".
|
||||||
await inst.db.insert(schema.planes).values({
|
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,
|
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
||||||
});
|
});
|
||||||
const cashBefore = (await getLocalPlayer(inst)).cash;
|
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);
|
expect(payout).toBe(Math.round(PLANE_TYPES.atr72.price * SELL_FRACTION * 100) / 100);
|
||||||
const player = await getLocalPlayer(inst);
|
const player = await getLocalPlayer(inst);
|
||||||
@@ -621,22 +621,22 @@ describe('sellPlane', () => {
|
|||||||
it('rejects selling a flying plane with 409', async () => {
|
it('rejects selling a flying plane with 409', async () => {
|
||||||
const inst = await freshDb('sell-flying.db');
|
const inst = await freshDb('sell-flying.db');
|
||||||
await inst.db.insert(schema.planes).values({
|
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,
|
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 () => {
|
it('rejects selling a grounded plane that still carries cargo with 409 (v0.28.0)', async () => {
|
||||||
const inst = await freshDb('sell-cargo.db');
|
const inst = await freshDb('sell-cargo.db');
|
||||||
await inst.db.insert(schema.planes).values({
|
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,
|
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
||||||
});
|
});
|
||||||
await insertOpenPackage(inst, 'p-loaded', { status: 'loaded', planeId: 'plane-extra' });
|
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);
|
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
|
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 () => {
|
it('rejects selling a grounded plane with queued legs with 409 (v0.46.0, ISS-088)', async () => {
|
||||||
const inst = await freshDb('sell-queue.db');
|
const inst = await freshDb('sell-queue.db');
|
||||||
await inst.db.insert(schema.planes).values({
|
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,
|
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];
|
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
|
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 () => {
|
it('rejects selling the last remaining plane with 409', async () => {
|
||||||
const inst = await freshDb('sell-last.db'); // only the starter exists
|
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 () => {
|
it('rejects an unknown id with 404', async () => {
|
||||||
const inst = await freshDb('sell-unknown.db');
|
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'));
|
await inst.db.update(schema.planes).set({ health: 42 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
const cashBefore = (await getLocalPlayer(inst)).cash;
|
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);
|
expect(cost).toBe(REPAIR_COST);
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
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 inst = await freshDb('repair-full.db'); // seeded starter at 100
|
||||||
const cashBefore = (await getLocalPlayer(inst)).cash;
|
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);
|
expect(cost).toBe(0);
|
||||||
const plane = (await inst.db.select().from(schema.planes))[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' })
|
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
.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 () => {
|
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 })
|
.set({ status: 'repairing', repairUntilSimTime: T0 + REPAIR_DURATION_MS })
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
.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 () => {
|
it('blocks load/fly/sell while repairing (the existing status guards)', async () => {
|
||||||
const inst = await freshDb('repair-blocks.db');
|
const inst = await freshDb('repair-blocks.db');
|
||||||
await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter'));
|
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 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(loadPackage(inst, 'p-open', 'plane-starter', 'local'), 409, /repairing/);
|
||||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 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.
|
// A second plane so the "last remaining" guard cannot fire before the status one.
|
||||||
await inst.db.insert(schema.planes).values({
|
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,
|
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 () => {
|
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 inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
await updateLocalPlayerEconomy(inst, { cash: REPAIR_COST - 1 });
|
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 () => {
|
it('rejects an unknown id with 404', async () => {
|
||||||
const inst = await freshDb('repair-unknown.db');
|
const inst = await freshDb('repair-unknown.db');
|
||||||
await expectActionError(repairPlane(inst, 'ghost'), 404);
|
await expectActionError(repairPlane(inst, 'ghost', 'local'), 404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ownership (v1.2.0, ISS-109)', () => {
|
||||||
|
// A second player row so per-player economy reads don't throw; the seeded starter is owned by 'local'.
|
||||||
|
async function addPlayerB(inst: GameDb): Promise<void> {
|
||||||
|
await inst.db.insert(schema.players).values({ id: 'b', provider: 'local', username: 'player-b', cash: STARTING_CASH, reputation: STARTING_REPUTATION });
|
||||||
|
}
|
||||||
|
|
||||||
|
it('rejects every plane action with 403 when the session player does not own the plane', async () => {
|
||||||
|
const inst = await freshDb('own-403.db');
|
||||||
|
await addPlayerB(inst);
|
||||||
|
await insertOpenPackage(inst, 'p-own');
|
||||||
|
|
||||||
|
await expectActionError(loadPackage(inst, 'p-own', 'plane-starter', 'b'), 403, /not your plane/);
|
||||||
|
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD', 'b'), 403, /not your plane/);
|
||||||
|
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD', 'b'), 403, /not your plane/);
|
||||||
|
await expectActionError(cancelQueuedLeg(inst, 'plane-starter', 0, 'b'), 403, /not your plane/);
|
||||||
|
|
||||||
|
// Load as the owner first so abandon has a bound package to check…
|
||||||
|
await loadPackage(inst, 'p-own', 'plane-starter', 'local');
|
||||||
|
await expectActionError(abandonPackage(inst, 'p-own', 'b'), 403, /not your plane/);
|
||||||
|
|
||||||
|
await expectActionError(sellPlane(inst, 'plane-starter', 'b'), 403, /not your plane/);
|
||||||
|
await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
await expectActionError(repairPlane(inst, 'plane-starter', 'b'), 403, /not your plane/);
|
||||||
|
|
||||||
|
// Nothing was mutated by the rejected attempts.
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('available');
|
||||||
|
expect(JSON.parse(plane.queue)).toEqual([]);
|
||||||
|
expect((await getLocalPlayer(inst)).cash).toBe(STARTING_CASH); // no money moved for either player
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects actions on another player\'s plane with 403 too (both directions)', async () => {
|
||||||
|
const inst = await freshDb('own-403-rev.db');
|
||||||
|
await addPlayerB(inst);
|
||||||
|
await inst.db.insert(schema.planes).values({
|
||||||
|
id: 'plane-b', ownerId: 'b', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||||
|
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectActionError(flyPlane(inst, 'plane-b', 'LEMD', 'local'), 403, /not your plane/);
|
||||||
|
await expectActionError(sellPlane(inst, 'plane-b', 'local'), 403, /not your plane/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces the last-plane guard per owner — not on the world total', async () => {
|
||||||
|
const inst = await freshDb('own-lastplane.db');
|
||||||
|
await addPlayerB(inst);
|
||||||
|
// 'b' owns two planes; 'local' only the starter. World total: three.
|
||||||
|
for (const id of ['plane-b1', 'plane-b2']) {
|
||||||
|
await inst.db.insert(schema.planes).values({
|
||||||
|
id, ownerId: 'b', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||||
|
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selling local's only plane is still forbidden even though the world has three planes…
|
||||||
|
await expectActionError(sellPlane(inst, 'plane-starter', 'local'), 409, /last remaining/);
|
||||||
|
// …while b can sell one of their two.
|
||||||
|
const cashBefore = (await getPlayer(inst, 'b')).cash;
|
||||||
|
const { payout } = await sellPlane(inst, 'plane-b1', 'b');
|
||||||
|
|
||||||
|
expect(payout).toBe(Math.round(PLANE_TYPES.atr72.price * SELL_FRACTION * 100) / 100);
|
||||||
|
expect((await getPlayer(inst, 'b')).cash).toBeCloseTo(cashBefore + payout, 6); // b's own wallet pays out…
|
||||||
|
expect((await getLocalPlayer(inst)).cash).toBe(STARTING_CASH); // …local's is untouched
|
||||||
|
const ids = (await inst.db.select().from(schema.planes)).map((p) => p.id).sort();
|
||||||
|
expect(ids).toEqual(['plane-b2', 'plane-starter']); // b1 gone; local's starter untouched
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stamps the buyer as owner and debits their own cash (not the local player\'s)', async () => {
|
||||||
|
const inst = await freshDb('own-buy.db');
|
||||||
|
await addPlayerB(inst);
|
||||||
|
await updatePlayerEconomy(inst, 'b', { cash: 500_000 });
|
||||||
|
|
||||||
|
const { planeId } = await buyPlane(inst, 'atr72', 'b');
|
||||||
|
|
||||||
|
const bought = (await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)))[0];
|
||||||
|
expect(bought.ownerId).toBe('b');
|
||||||
|
expect((await getPlayer(inst, 'b')).cash).toBe(500_000 - PLANE_TYPES.atr72.price);
|
||||||
|
expect((await getLocalPlayer(inst)).cash).toBe(STARTING_CASH); // local untouched
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+45
-23
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import { and, eq, inArray } from 'drizzle-orm';
|
import { and, eq, inArray } from 'drizzle-orm';
|
||||||
import { requireAuth } from '@/lib/auth';
|
import { requireAuth } from '@/lib/auth';
|
||||||
import * as schema from '@/lib/db/schema';
|
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 { persist, type GameDb } from '@/lib/db/client';
|
||||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||||
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||||
@@ -35,6 +35,7 @@ import { HUB_ICAO } from './seed';
|
|||||||
// 404 — unknown entity id
|
// 404 — unknown entity id
|
||||||
// 409 — valid ids but the current state forbids the action (wrong package/plane
|
// 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)
|
// 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)
|
// 400 — bad input that isn't an existence problem (unknown airport/model)
|
||||||
|
|
||||||
export class GameActionError extends Error {
|
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 };
|
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}) ---
|
// --- Load (POST /api/packages/:id/load {planeId}) ---
|
||||||
|
|
||||||
// open → loaded, bound to the plane. The plane must be GROUNDED at the package's
|
// 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
|
// 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
|
// 'loaded' or 'expired' on it — expired cargo keeps occupying its space until
|
||||||
// delivered/abandoned).
|
// 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([
|
const [pkgRows, planeRows] = await Promise.all([
|
||||||
inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)),
|
inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId)),
|
||||||
inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)),
|
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];
|
const plane = planeRows[0];
|
||||||
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
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') {
|
if (plane.status !== 'available') {
|
||||||
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can load cargo`);
|
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
|
// 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
|
// 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.
|
// 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 planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
||||||
const plane = planeRows[0];
|
const plane = planeRows[0];
|
||||||
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
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') {
|
if (plane.status !== 'available') {
|
||||||
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can fly`);
|
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.
|
// any mutation, so a 409 leaves fuel and cash untouched.
|
||||||
const target = Math.min(plane.maxFuel, Math.max(plane.fuel, requiredL * 1.5));
|
const target = Math.min(plane.maxFuel, Math.max(plane.fuel, requiredL * 1.5));
|
||||||
const liters = Math.max(0, target - plane.fuel);
|
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;
|
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) {
|
if (cost > 0 && player.cash < cost) {
|
||||||
throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(player.cash)}`);
|
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));
|
.where(eq(schema.planes.id, planeId));
|
||||||
if (cost > 0) {
|
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);
|
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
|
// 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
|
// covers them (lib/sim/engine.ts deliverPackages). Validation: known plane, ICAO
|
||||||
// in the airport dataset.
|
// 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 planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
||||||
const plane = planeRows[0];
|
const plane = planeRows[0];
|
||||||
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
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();
|
const fc = await loadAirportsGeoJson();
|
||||||
if (!fc.features.some((f) => f.properties.icao === destIcao)) {
|
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
|
// 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:
|
// cancellable — the active in-flight leg (destIcao) is never affected. Validation:
|
||||||
// known plane, integer index within [0, queue.length).
|
// 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 planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
||||||
const plane = planeRows[0];
|
const plane = planeRows[0];
|
||||||
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
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);
|
const queue = parseIcaoQueue(plane.queue);
|
||||||
if (index < 0 || index >= queue.length) {
|
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
|
// 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
|
// lib/game/penalty.ts); an already-expired package abandons for free. Cash never
|
||||||
// goes below €0. The slot is freed immediately.
|
// 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 pkgRows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId));
|
||||||
const pkg = pkgRows[0];
|
const pkg = pkgRows[0];
|
||||||
if (!pkg) throw new GameActionError(404, `package ${packageId} not found`);
|
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]
|
? (await inst.db.select().from(schema.planes).where(eq(schema.planes.id, pkg.planeId)))[0]
|
||||||
: undefined;
|
: undefined;
|
||||||
if (!plane) throw new GameActionError(409, `package ${packageId} is not bound to a plane`);
|
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') {
|
if (plane.status !== 'available') {
|
||||||
throw new GameActionError(409, `the plane is in flight — packages can only be abandoned while it is grounded`);
|
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');
|
if (!sim) throw new Error('sim_state row missing');
|
||||||
const penalty = abandonPenalty(pkg, sim.clockMs);
|
const penalty = abandonPenalty(pkg, sim.clockMs);
|
||||||
|
|
||||||
const player = await getLocalPlayer(inst);
|
// v1.2.0 (ISS-109): the acting player's own economy pays the penalty.
|
||||||
await updateLocalPlayerEconomy(inst, { cash: Math.max(0, Math.round((player.cash - penalty) * 100) / 100) });
|
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 inst.db.update(schema.packages).set({ status: 'abandoned', planeId: null }).where(eq(schema.packages.id, packageId));
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
return { penalty };
|
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
|
// 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
|
// reputation, exactly full price at the starting value); it arrives 'available' with a full tank
|
||||||
// at the home base (EDDF).
|
// 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];
|
const type = PLANE_TYPES[model];
|
||||||
if (!type) throw new GameActionError(400, `unknown plane model "${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.
|
// 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;
|
const cost = Math.round(type.price * (1 - repRate(player.reputation, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)) * 100) / 100;
|
||||||
if (player.cash < cost) {
|
if (player.cash < cost) {
|
||||||
@@ -286,6 +300,7 @@ export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId:
|
|||||||
const planeId = randomUUID();
|
const planeId = randomUUID();
|
||||||
await inst.db.insert(schema.planes).values({
|
await inst.db.insert(schema.planes).values({
|
||||||
id: planeId,
|
id: planeId,
|
||||||
|
ownerId: playerId, // v1.2.0 (ISS-109): the buyer owns what they buy
|
||||||
model,
|
model,
|
||||||
status: 'available',
|
status: 'available',
|
||||||
fuel: type.maxFuel, // full tank on delivery
|
fuel: type.maxFuel, // full tank on delivery
|
||||||
@@ -297,7 +312,7 @@ export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId:
|
|||||||
departureSimTime: null,
|
departureSimTime: null,
|
||||||
health: 100, // new planes arrive at full health (v0.39.0, ISS-069)
|
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);
|
await persist(inst);
|
||||||
return { planeId };
|
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
|
// 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.
|
// 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 rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
||||||
const plane = rows[0];
|
const plane = rows[0];
|
||||||
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
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') {
|
if (plane.status !== 'available') {
|
||||||
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be sold`);
|
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');
|
throw new GameActionError(409, 'the plane has queued legs — cancel queued legs first');
|
||||||
}
|
}
|
||||||
|
|
||||||
const all = await inst.db.select().from(schema.planes);
|
// v1.2.0 (ISS-109): the guard is PER OWNER — you can't sell your last plane even if other
|
||||||
if (all.length <= 1) throw new GameActionError(409, 'cannot sell the last remaining plane');
|
// 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];
|
const type = PLANE_TYPES[plane.model];
|
||||||
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
|
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;
|
const payout = Math.round(type.price * SELL_FRACTION * 100) / 100;
|
||||||
|
|
||||||
await inst.db.delete(schema.planes).where(eq(schema.planes.id, planeId));
|
await inst.db.delete(schema.planes).where(eq(schema.planes.id, planeId));
|
||||||
const player = await getLocalPlayer(inst);
|
const player = await getPlayer(inst, playerId);
|
||||||
await updateLocalPlayerEconomy(inst, { cash: Math.round((player.cash + payout) * 100) / 100 });
|
await updatePlayerEconomy(inst, playerId, { cash: Math.round((player.cash + payout) * 100) / 100 });
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
return { payout };
|
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
|
// 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
|
// planes can be repaired (409 otherwise); an already-full-health repair is a free no-op. 404 unknown
|
||||||
// plane; 409 not grounded / insufficient funds.
|
// 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 rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
||||||
const plane = rows[0];
|
const plane = rows[0];
|
||||||
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
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') {
|
if (plane.status !== 'available') {
|
||||||
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be repaired`);
|
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];
|
const sim = (await inst.db.select().from(schema.simState))[0];
|
||||||
if (!sim) throw new Error('sim_state row missing');
|
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) {
|
if (player.cash < REPAIR_COST) {
|
||||||
throw new GameActionError(409, `insufficient funds: repair costs ${REPAIR_COST}, you have ${Math.round(player.cash)}`);
|
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)
|
await inst.db.update(schema.planes)
|
||||||
.set({ status: 'repairing', repairUntilSimTime: sim.clockMs + REPAIR_DURATION_MS })
|
.set({ status: 'repairing', repairUntilSimTime: sim.clockMs + REPAIR_DURATION_MS })
|
||||||
.where(eq(schema.planes.id, planeId));
|
.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);
|
await persist(inst);
|
||||||
return { cost: REPAIR_COST };
|
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.
|
// 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
|
// 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.
|
// 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);
|
const auth = await requireAuth(req);
|
||||||
if (!auth.ok) return auth.response;
|
if (!auth.ok) return auth.response;
|
||||||
try {
|
try {
|
||||||
await runAction(fn);
|
await runAction((inst) => fn(inst, auth.player.id));
|
||||||
return Response.json(await getStateSnapshot());
|
return Response.json(await getStateSnapshot());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof GameActionError) {
|
if (err instanceof GameActionError) {
|
||||||
|
|||||||
+18
-11
@@ -360,19 +360,14 @@ function pickHub(airports: SeedAirport[]): SeedAirport {
|
|||||||
return airports.find((a) => a.icao === HUB_ICAO) ?? airports[0];
|
return airports.find((a) => a.icao === HUB_ICAO) ?? airports[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSeed(input: {
|
// v1.2.0 (ISS-108): the starter plane, extracted from buildSeed so per-player onboarding can reuse
|
||||||
airports: SeedAirport[];
|
// it — same values as the fresh-install seed's plane (no RNG draws), only the id differs. The
|
||||||
rng: Rng;
|
// caller stamps ownership (planes.owner_id) at insert time; this stays a pure SeedPlane.
|
||||||
nowMs: number;
|
export function buildStarterPlane(airports: SeedAirport[], id: string): SeedPlane {
|
||||||
}): SeedData {
|
|
||||||
const { airports, rng, nowMs } = input;
|
|
||||||
if (airports.length < 2) throw new Error('seed needs at least two airports');
|
|
||||||
|
|
||||||
const type = PLANE_TYPES[STARTER_MODEL];
|
const type = PLANE_TYPES[STARTER_MODEL];
|
||||||
const hub = pickHub(airports);
|
const hub = pickHub(airports);
|
||||||
|
return {
|
||||||
const plane: SeedPlane = {
|
id,
|
||||||
id: 'plane-starter',
|
|
||||||
model: STARTER_MODEL,
|
model: STARTER_MODEL,
|
||||||
status: 'available',
|
status: 'available',
|
||||||
fuel: type.maxFuel, // starts full
|
fuel: type.maxFuel, // starts full
|
||||||
@@ -384,6 +379,18 @@ export function buildSeed(input: {
|
|||||||
queue: [], // no queued legs on first run (v0.25.0)
|
queue: [], // no queued legs on first run (v0.25.0)
|
||||||
health: 100, // full health on first run (v0.39.0, ISS-069)
|
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
|
// 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.
|
// composition (POOL_BANDS) the runtime spawner maintains, so first run looks like any other tick.
|
||||||
|
|||||||
+9
-8
@@ -185,16 +185,17 @@ describe('OpenAPI spec', () => {
|
|||||||
expect(ok.content['application/json'].schema.$ref).toBe('#/components/schemas/StateSnapshot');
|
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();
|
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}/load')).toEqual(['400', '403', '404', '409']);
|
||||||
expect(errorCodes('/api/packages/{id}/abandon')).toEqual(['404', '409']);
|
expect(errorCodes('/api/packages/{id}/abandon')).toEqual(['403', '404', '409']);
|
||||||
expect(errorCodes('/api/planes/{planeId}/fly')).toEqual(['400', '404', '409']);
|
expect(errorCodes('/api/planes/{planeId}/fly')).toEqual(['400', '403', '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')).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', '404']); // v0.25.0
|
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/buy-plane')).toEqual(['400', '409']);
|
||||||
expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '404', '409']);
|
expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '403', '404', '409']);
|
||||||
expect(errorCodes('/api/planes/{planeId}/repair')).toEqual(['404', '409']); // v0.39.0 (ISS-069) — no body, so no 400
|
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.
|
// 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(
|
expect(post('/api/packages/{id}/load').requestBody?.content['application/json'].schema.$ref).toBe(
|
||||||
|
|||||||
@@ -110,6 +110,10 @@ export const openApiSpec = {
|
|||||||
description: 'Invalid body (not an object, unknown field, or missing/non-string planeId)',
|
description: 'Invalid body (not an object, unknown field, or missing/non-string planeId)',
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
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: {
|
404: {
|
||||||
description: 'Unknown package id or plane id',
|
description: 'Unknown package id or plane id',
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
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)',
|
description: 'Abandoned — full state snapshot (package "abandoned", cash reduced by the penalty)',
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
|
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: {
|
404: {
|
||||||
description: 'Unknown package id',
|
description: 'Unknown package id',
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
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',
|
'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' } } },
|
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: {
|
404: {
|
||||||
description: 'Unknown plane id',
|
description: 'Unknown plane id',
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
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',
|
'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' } } },
|
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: {
|
404: {
|
||||||
description: 'Unknown plane id',
|
description: 'Unknown plane id',
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
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)',
|
'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' } } },
|
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: {
|
404: {
|
||||||
description: 'Unknown plane id',
|
description: 'Unknown plane id',
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
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)',
|
description: 'Invalid body (not an object, unknown field, or missing/non-string planeId)',
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
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: {
|
404: {
|
||||||
description: 'Unknown plane id',
|
description: 'Unknown plane id',
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
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)',
|
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' } } },
|
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: {
|
404: {
|
||||||
description: 'Unknown plane id',
|
description: 'Unknown plane id',
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
|||||||
@@ -783,7 +783,7 @@ describe('spawnPoolPackages (v0.65.0 per-airport pools)', () => {
|
|||||||
.set({ status: 'flying', destIcao: 'LEMD', departureSimTime: T0, queue: JSON.stringify(['EHAM']) })
|
.set({ status: 'flying', destIcao: 'LEMD', departureSimTime: T0, queue: JSON.stringify(['EHAM']) })
|
||||||
.where(eq(schema.planes.id, 'plane-starter'));
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
await inst.db.insert(schema.planes).values({
|
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);
|
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
|
// grounded — pools at EDDF (grounded) and LEMD (flying destination). Arrival is hours out, so it
|
||||||
// never lands here.
|
// never lands here.
|
||||||
await inst.db.insert(schema.planes).values({
|
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,
|
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
|
vi.setSystemTime(T0 + 1_000); // one real second → advanceMs > 0 at the seeded scale
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flight-ops",
|
"name": "flight-ops",
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
Reference in New Issue
Block a user