Right pane tabs Jobs/Planes market + 5 new plane types with tech-spec panels + sell-plane cargo guard (ISS-047 part A)
CI / build + unit + e2e (pull_request) Successful in 8m56s
CI / build + unit + e2e (pull_request) Successful in 8m56s
This commit is contained in:
@@ -440,6 +440,19 @@ describe('sellPlane', () => {
|
||||
await expectActionError(sellPlane(inst, 'plane-extra'), 409, /flying/);
|
||||
});
|
||||
|
||||
it('rejects selling a grounded plane that still carries cargo with 409 (v0.28.0)', async () => {
|
||||
const inst = await freshDb('sell-cargo.db');
|
||||
await inst.db.insert(schema.planes).values({
|
||||
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
||||
});
|
||||
await insertOpenPackage(inst, 'p-loaded', { status: 'loaded', planeId: 'plane-extra' });
|
||||
|
||||
await expectActionError(sellPlane(inst, 'plane-extra'), 409, /carrying cargo/);
|
||||
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
|
||||
});
|
||||
|
||||
it('rejects selling the last remaining plane with 409', async () => {
|
||||
const inst = await freshDb('sell-last.db'); // only the starter exists
|
||||
await expectActionError(sellPlane(inst, 'plane-starter'), 409, /last remaining/);
|
||||
|
||||
@@ -255,6 +255,15 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
|
||||
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be sold`);
|
||||
}
|
||||
|
||||
// v0.28.0: selling a plane that still carries cargo would orphan it — the packages stay
|
||||
// 'loaded'/'expired' with a dangling planeId and could never deliver (same query pattern as loadPackage).
|
||||
const carrying = await inst.db.select({ id: schema.packages.id }).from(schema.packages).where(
|
||||
and(inArray(schema.packages.status, ['loaded', 'expired']), eq(schema.packages.planeId, plane.id)),
|
||||
);
|
||||
if (carrying.length > 0) {
|
||||
throw new GameActionError(409, `cannot sell a plane carrying cargo`);
|
||||
}
|
||||
|
||||
const all = await inst.db.select().from(schema.planes);
|
||||
if (all.length <= 1) throw new GameActionError(409, 'cannot sell the last remaining plane');
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PLANE_TYPES, STARTER_MODEL } from './catalog';
|
||||
|
||||
// v0.28.0 (ISS-047) — catalog sanity: the progression ladder must stay well-formed as types are added.
|
||||
describe('PLANE_TYPES', () => {
|
||||
it('every entry has positive specs and a unique id', () => {
|
||||
const ids = Object.keys(PLANE_TYPES);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
for (const t of Object.values(PLANE_TYPES)) {
|
||||
expect(t.id, `${t.name} id`).toBeTruthy();
|
||||
expect(t.price, `${t.name} price`).toBeGreaterThan(0);
|
||||
expect(t.maxFuel, `${t.name} maxFuel`).toBeGreaterThan(0);
|
||||
expect(t.burnPerKm, `${t.name} burnPerKm`).toBeGreaterThan(0);
|
||||
expect(t.emptyWeightKg, `${t.name} emptyWeightKg`).toBeGreaterThan(0);
|
||||
expect(t.speedKmh, `${t.name} speedKmh`).toBeGreaterThan(0);
|
||||
expect(t.maxPayloadKg, `${t.name} maxPayloadKg`).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('atr72 is the starter model', () => {
|
||||
expect(STARTER_MODEL).toBe('atr72');
|
||||
expect(PLANE_TYPES[STARTER_MODEL]).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,60 @@ export const PLANE_TYPES: Record<string, PlaneType> = {
|
||||
// must stay below this so any single package fits alone; typical loads carry ~3–6 packages.
|
||||
maxPayloadKg: 4_800,
|
||||
},
|
||||
// Progression ladder above the ATR starter (v0.28.0, ISS-047): bigger tanks, faster airframes,
|
||||
// higher prices — everything downstream is data-driven via PLANE_TYPES[model]. Values are
|
||||
// balance-tunable; keep maxPayloadKg ≥ every cargo type at max count × 1.3 jitter so any
|
||||
// single package still fits alone (same rule as the ATR above).
|
||||
crj900: {
|
||||
id: 'crj900',
|
||||
name: 'CRJ-900',
|
||||
maxFuel: 8_000,
|
||||
burnPerKm: 2.1,
|
||||
emptyWeightKg: 17_500,
|
||||
speedKmh: 830,
|
||||
price: 450_000,
|
||||
maxPayloadKg: 6_500,
|
||||
},
|
||||
e175: {
|
||||
id: 'e175',
|
||||
name: 'Embraer E175',
|
||||
maxFuel: 9_500,
|
||||
burnPerKm: 2.0,
|
||||
emptyWeightKg: 23_000,
|
||||
speedKmh: 840,
|
||||
price: 900_000,
|
||||
maxPayloadKg: 9_000,
|
||||
},
|
||||
a320: {
|
||||
id: 'a320',
|
||||
name: 'Airbus A320-200',
|
||||
maxFuel: 12_000,
|
||||
burnPerKm: 2.2,
|
||||
emptyWeightKg: 41_000,
|
||||
speedKmh: 845,
|
||||
price: 1_800_000,
|
||||
maxPayloadKg: 18_000,
|
||||
},
|
||||
a330: {
|
||||
id: 'a330',
|
||||
name: 'Airbus A330-200',
|
||||
maxFuel: 20_000,
|
||||
burnPerKm: 2.16,
|
||||
emptyWeightKg: 95_000,
|
||||
speedKmh: 910,
|
||||
price: 4_500_000,
|
||||
maxPayloadKg: 48_000,
|
||||
},
|
||||
b777: {
|
||||
id: 'b777',
|
||||
name: 'Boeing 777-300ER',
|
||||
maxFuel: 30_000,
|
||||
burnPerKm: 2.5,
|
||||
emptyWeightKg: 130_000,
|
||||
speedKmh: 920,
|
||||
price: 9_000_000,
|
||||
maxPayloadKg: 80_000,
|
||||
},
|
||||
};
|
||||
|
||||
// The plane the player starts with.
|
||||
|
||||
Reference in New Issue
Block a user