v1.0.0: players table + migration from singleton account (ISS-105)
CI / build + unit + e2e (pull_request) Successful in 14m25s

This commit is contained in:
2026-09-24 14:26:34 +00:00
parent 837aa60a7d
commit b2034ebc52
21 changed files with 258 additions and 166 deletions
+36 -35
View File
@@ -5,6 +5,7 @@ import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { openGameDb, type GameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer';
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather';
@@ -204,14 +205,14 @@ describe('flyPlane', () => {
const inst = await freshDb('fly-broken.db'); // EDDF → LEMD
const fuelBefore = PLANE_TYPES.atr72.maxFuel; // seeded full tank
await inst.db.update(schema.planes).set({ health: 0 }).where(eq(schema.planes.id, 'plane-starter'));
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
const cashBefore = (await getLocalPlayer(inst)).cash;
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /broken \(health 0\)/);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('available'); // still grounded…
expect(plane.fuel).toBe(fuelBefore); // …no auto-refuel ran — the tank is untouched…
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore); // …and no cash left the account
expect((await getLocalPlayer(inst)).cash).toBe(cashBefore); // …and no cash left the account
});
it('still flies a worn-but-not-broken plane (health > 0) with plain burn', async () => {
@@ -249,8 +250,8 @@ describe('flyPlane', () => {
const startFuel = Math.round(required / 2); // partial tank below the requirement
await inst.db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
// Elevated rep → non-zero discount rate; expectations computed with the SAME helpers flyPlane uses.
await inst.db.update(schema.account).set({ reputation: 30 });
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
await updateLocalPlayerEconomy(inst, { reputation: 30 });
const cashBefore = (await getLocalPlayer(inst)).cash;
await flyPlane(inst, 'plane-starter', 'LEMD');
@@ -263,22 +264,22 @@ describe('flyPlane', () => {
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying');
expect(plane.fuel).toBeCloseTo(target, 6); // topped up to exactly the 150% target…
const account = (await inst.db.select().from(schema.account))[0];
expect(account.cash).toBeCloseTo(cashBefore - expectedCost, 6); // …at exactly the rep-discounted cost
const player = await getLocalPlayer(inst);
expect(player.cash).toBeCloseTo(cashBefore - expectedCost, 6); // …at exactly the rep-discounted cost
});
it('is a free no-op when the tank already covers the 150% target (cash unchanged)', async () => {
const inst = await freshDb('fly-autorefuel-full.db'); // seeded starter is full at EDDF
const required = await adjustedRequiredForHubToLemd();
expect(required * 1.5).toBeLessThan(PLANE_TYPES.atr72.maxFuel); // sanity: the target stays below a full tank
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
const cashBefore = (await getLocalPlayer(inst)).cash;
await flyPlane(inst, 'plane-starter', 'LEMD');
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying');
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel); // untouched…
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore); // …and free
expect((await getLocalPlayer(inst)).cash).toBe(cashBefore); // …and free
});
it('rejects dispatch with 409 insufficient funds when cash cannot cover the top-up — nothing mutated', async () => {
@@ -286,7 +287,7 @@ describe('flyPlane', () => {
const required = await adjustedRequiredForHubToLemd();
const startFuel = Math.round(required / 2);
await inst.db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
await inst.db.update(schema.account).set({ cash: 0 });
await updateLocalPlayerEconomy(inst, { cash: 0 });
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /insufficient funds/i);
@@ -451,7 +452,7 @@ describe('abandonPackage', () => {
it('charges exactly 15% of the payout right after creation (clock at createdSimTime)', async () => {
const inst = await setupLoaded('ab-start.db');
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
const cashBefore = (await getLocalPlayer(inst)).cash;
const { penalty } = await abandonPackage(inst, 'p-ab');
@@ -459,8 +460,8 @@ describe('abandonPackage', () => {
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ab')))[0];
expect(row.status).toBe('abandoned');
expect(row.planeId).toBeNull(); // slot freed
const account = (await inst.db.select().from(schema.account))[0];
expect(account.cash).toBeCloseTo(cashBefore - penalty, 6);
const player = await getLocalPlayer(inst);
expect(player.cash).toBeCloseTo(cashBefore - penalty, 6);
});
it('scales to exactly 37.5% at the midpoint of the window', async () => {
@@ -487,24 +488,24 @@ describe('abandonPackage', () => {
it('is free for an already-expired package', async () => {
const inst = await setupLoaded('ab-expired.db', 'expired');
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
const cashBefore = (await getLocalPlayer(inst)).cash;
const { penalty } = await abandonPackage(inst, 'p-ab');
expect(penalty).toBe(0);
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore);
expect((await getLocalPlayer(inst)).cash).toBe(cashBefore);
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ab')))[0];
expect(row.status).toBe('abandoned');
});
it('floors cash at €0 — no debt', async () => {
const inst = await setupLoaded('ab-floor.db');
await inst.db.update(schema.account).set({ cash: 10 }); // penalty is 150 > 10
await updateLocalPlayerEconomy(inst, { cash: 10 }); // penalty is 150 > 10
const { penalty } = await abandonPackage(inst, 'p-ab');
expect(penalty).toBe(150);
expect((await inst.db.select().from(schema.account))[0].cash).toBe(0);
expect((await getLocalPlayer(inst)).cash).toBe(0);
});
it('rejects abandoning while the plane is in flight with 409', async () => {
@@ -532,13 +533,13 @@ describe('abandonPackage', () => {
describe('buyPlane', () => {
it('deducts the full price and delivers a full-tank plane at the home base', async () => {
const inst = await freshDb('buy-ok.db');
await inst.db.update(schema.account).set({ cash: 500_000 });
await updateLocalPlayerEconomy(inst, { cash: 500_000 });
const before = (await inst.db.select().from(schema.planes)).length;
const { planeId } = await buyPlane(inst, 'atr72');
const account = (await inst.db.select().from(schema.account))[0];
expect(account.cash).toBe(500_000 - PLANE_TYPES.atr72.price);
const player = await getLocalPlayer(inst);
expect(player.cash).toBe(500_000 - PLANE_TYPES.atr72.price);
const planes = await inst.db.select().from(schema.planes);
expect(planes.length).toBe(before + 1);
@@ -567,7 +568,7 @@ describe('buyPlane', () => {
it('deducts the reputation-scaled cost at elevated rep (v0.38.0)', async () => {
const inst = await freshDb('buy-rep.db');
// rep 30 → rate exactly 0.2 → ATR costs 160k, not 200k.
await inst.db.update(schema.account).set({ cash: 500_000, reputation: 30 });
await updateLocalPlayerEconomy(inst, { cash: 500_000, reputation: 30 });
const before = (await inst.db.select().from(schema.planes)).length;
await buyPlane(inst, 'atr72');
@@ -576,23 +577,23 @@ describe('buyPlane', () => {
expect(rate).toBeCloseTo(0.2, 12); // sanity pin on the formula input
const expectedCost = Math.round(PLANE_TYPES.atr72.price * (1 - rate) * 100) / 100;
expect(expectedCost).toBe(160_000); // 200k × 0.8 — exact value pin
const account = (await inst.db.select().from(schema.account))[0];
expect(account.cash).toBeCloseTo(500_000 - expectedCost, 6);
const player = await getLocalPlayer(inst);
expect(player.cash).toBeCloseTo(500_000 - expectedCost, 6);
expect((await inst.db.select().from(schema.planes)).length).toBe(before + 1);
});
it('buys with cash between the discounted and full price; rejects below the discounted cost (v0.38.0)', async () => {
const inst = await freshDb('buy-boundary.db');
// rep 30 → discounted cost 160k: 170k is enough now, though it was not before v0.38.0.
await inst.db.update(schema.account).set({ cash: 170_000, reputation: 30 });
await updateLocalPlayerEconomy(inst, { cash: 170_000, reputation: 30 });
const { planeId } = await buyPlane(inst, 'atr72');
expect(planeId).toBeTruthy(); // the old full-price check would have rejected this
const account = (await inst.db.select().from(schema.account))[0];
expect(account.cash).toBeCloseTo(170_000 - 160_000, 6);
const player = await getLocalPlayer(inst);
expect(player.cash).toBeCloseTo(170_000 - 160_000, 6);
const inst2 = await freshDb('buy-boundary-poor.db'); // just below the discounted cost
await inst2.db.update(schema.account).set({ cash: 159_999.99, reputation: 30 });
await updateLocalPlayerEconomy(inst2, { cash: 159_999.99, reputation: 30 });
await expectActionError(buyPlane(inst2, 'atr72'), 409, /insufficient funds/i);
});
});
@@ -605,13 +606,13 @@ describe('sellPlane', () => {
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
});
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
const cashBefore = (await getLocalPlayer(inst)).cash;
const { payout } = await sellPlane(inst, 'plane-extra');
expect(payout).toBe(Math.round(PLANE_TYPES.atr72.price * SELL_FRACTION * 100) / 100);
const account = (await inst.db.select().from(schema.account))[0];
expect(account.cash).toBeCloseTo(cashBefore + payout, 6);
const player = await getLocalPlayer(inst);
expect(player.cash).toBeCloseTo(cashBefore + payout, 6);
const ids = (await inst.db.select().from(schema.planes)).map((p) => p.id);
expect(ids).not.toContain('plane-extra'); // row deleted
expect(ids).toContain('plane-starter');
@@ -668,7 +669,7 @@ describe('repairPlane (v0.39.0, ISS-069; gradual since v0.45.0, ISS-089)', () =>
it('charges exactly REPAIR_COST and grounds the plane for REPAIR_DURATION_MS without touching health yet', async () => {
const inst = await freshDb('repair-ok.db');
await inst.db.update(schema.planes).set({ health: 42 }).where(eq(schema.planes.id, 'plane-starter'));
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
const cashBefore = (await getLocalPlayer(inst)).cash;
const { cost } = await repairPlane(inst, 'plane-starter');
@@ -677,13 +678,13 @@ describe('repairPlane (v0.39.0, ISS-069; gradual since v0.45.0, ISS-089)', () =>
expect(plane.status).toBe('repairing');
expect(plane.health).toBe(42); // NOT restored yet — the engine's finishRepairs pass heals it gradually…
expect(plane.repairUntilSimTime).toBe(STARTING_CLOCK_MS + REPAIR_DURATION_MS); // …with the gate set from the seeded game clock (ISS-117; no tick in direct calls)
const account = (await inst.db.select().from(schema.account))[0];
expect(account.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6);
const player = await getLocalPlayer(inst);
expect(player.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6);
});
it('is a free no-op when health is already full', async () => {
const inst = await freshDb('repair-full.db'); // seeded starter at 100
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
const cashBefore = (await getLocalPlayer(inst)).cash;
const { cost } = await repairPlane(inst, 'plane-starter');
@@ -692,7 +693,7 @@ describe('repairPlane (v0.39.0, ISS-069; gradual since v0.45.0, ISS-089)', () =>
expect(plane.status).toBe('available'); // not grounded…
expect(plane.health).toBe(100);
expect(plane.repairUntilSimTime ?? null).toBeNull(); // …and no gate set
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore);
expect((await getLocalPlayer(inst)).cash).toBe(cashBefore);
});
it('rejects a flying plane with 409', async () => {
@@ -732,7 +733,7 @@ describe('repairPlane (v0.39.0, ISS-069; gradual since v0.45.0, ISS-089)', () =>
it('rejects the repair when cash is below REPAIR_COST with 409', async () => {
const inst = await freshDb('repair-poor.db');
await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter'));
await inst.db.update(schema.account).set({ cash: REPAIR_COST - 1 });
await updateLocalPlayerEconomy(inst, { cash: REPAIR_COST - 1 });
await expectActionError(repairPlane(inst, 'plane-starter'), 409, /insufficient funds/i);
});