feat: v0.12.0 — contracts → packages: load/fly/abandon gameplay (ATR 72-600 capacity 6, manual flight to any airport, time-scaled abandon penalty 15–60%), one-time DB migration preserving wallet/clock; new endpoints /api/packages/:id/load|abandon + /api/planes/:planeId/fly

This commit is contained in:
2026-09-18 11:02:56 +00:00
parent 0208673da7
commit ab2e9fa54a
49 changed files with 2424 additions and 1409 deletions
+36 -52
View File
@@ -13,8 +13,10 @@ function snapshot(overrides: Partial<StateSnapshot> = {}): StateSnapshot {
account: { cash: 0, reputation: 0 },
simState: { clockMs: 0, timeScale: 1, paused: false },
planes: [],
contracts: [],
catalog: [{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, speedKmh: 510 }],
packages: [],
catalog: [
{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, speedKmh: 510, capacity: 6 },
],
fuelPricePerL: 0.8,
sellFraction: 0.5,
...overrides,
@@ -30,11 +32,8 @@ describe('planesGeoJson', () => {
const fc = planesGeoJson(
snapshot({
planes: [
{ id: 'plane-starter', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', contractId: null, departureSimTime: null, lat: 50.03, lon: 8.56 },
{ id: 'plane-2', model: 'atr72', status: 'flying', fuel: 4000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', contractId: 'c1', departureSimTime: 1000, lat: 51.2, lon: 9.1 },
],
contracts: [
{ id: 'c1', originIcao: 'EDDF', originName: 'Frankfurt Main Airport', destIcao: 'EDDV', destName: 'Hannover Airport', reward: 100, cargoType: 'mail', cargoCount: 5, deadlineSimTime: 9e8, status: 'accepted', planeId: 'plane-2', createdSimTime: 0 },
{ id: 'plane-starter', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56 },
{ id: 'plane-2', model: 'atr72', status: 'flying', fuel: 4000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: 'EDDV', destName: 'Hannover Airport', departureSimTime: 1000, lat: 51.2, lon: 9.1 },
],
}),
);
@@ -52,7 +51,7 @@ describe('planesGeoJson', () => {
expect(flying.properties.flying).toBe(true);
expect(flying.properties.status).toBe('flying');
expect(flying.properties.fuelText).toBe('4000 / 6300 L');
expect(flying.properties.routeText).toBe('Frankfurt Main Airport → Hannover Airport');
expect(flying.properties.routeText).toBe('Frankfurt Main Airport → Hannover Airport'); // from the plane's own dest fields (v0.12.0)
});
it('falls back to the raw model id when the catalog has no entry', () => {
@@ -60,18 +59,18 @@ describe('planesGeoJson', () => {
snapshot({
catalog: [],
planes: [
{ id: 'p1', model: 'mystery', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', contractId: null, departureSimTime: null, lat: 0, lon: 0 },
{ id: 'p1', model: 'mystery', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0 },
],
}),
);
expect(fc.features[0].properties.modelLabel).toBe('mystery');
});
it('leaves routeText empty for a flying plane whose contract is missing from the snapshot', () => {
it('leaves routeText empty for a flying plane without a destination name', () => {
const fc = planesGeoJson(
snapshot({
planes: [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', contractId: 'gone', departureSimTime: 0, lat: 0, lon: 0 },
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: 0, lat: 0, lon: 0 },
],
}),
);
@@ -79,77 +78,62 @@ describe('planesGeoJson', () => {
});
});
describe('routesGeoJson', () => {
describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)', () => {
it('returns an empty collection for a null snapshot', () => {
expect(routesGeoJson(null, AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] });
});
it('draws nothing for open/completed/expired contracts', () => {
const mk = (status: string) => ({ id: `c-${status}`, originIcao: 'EDDF', originName: 'F', destIcao: 'EDDV', destName: 'H', reward: 1, cargoType: 'mail', cargoCount: 1, deadlineSimTime: 9e8, status, planeId: null, createdSimTime: 0 });
it('draws nothing for grounded planes, whatever their packages', () => {
const fc = routesGeoJson(
snapshot({ contracts: [mk('open'), mk('completed'), mk('expired')] as StateSnapshot['contracts'] }),
snapshot({
planes: [
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56 },
],
}),
AIRPORTS,
);
expect(fc.features).toHaveLength(0);
});
it('accepted-but-undispatched contract → pending line origin→dest', () => {
it('flying plane → one line origin→dest_icao with the plane id', () => {
const fc = routesGeoJson(
snapshot({
contracts: [
{ id: 'c1', originIcao: 'EDDF', originName: 'F', destIcao: 'EHAM', destName: 'A', reward: 1, cargoType: 'mail', cargoCount: 1, deadlineSimTime: 9e8, status: 'accepted', planeId: null, createdSimTime: 0 },
planes: [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9 },
],
}),
AIRPORTS,
);
expect(fc.features).toHaveLength(1);
expect(fc.features[0].properties).toEqual({ contractId: 'c1', kind: 'pending' });
expect(fc.features[0].properties).toEqual({ planeId: 'p1' });
expect(fc.features[0].geometry.coordinates).toEqual([
[8.56, 50.03], // EDDF [lon, lat]
[4.76, 52.31], // EHAM
]);
});
it('accepted + plane flying → inflight line', () => {
it('skips a flying plane whose endpoint is missing from the airport dataset', () => {
const fc = routesGeoJson(
snapshot({
planes: [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', contractId: 'c1', departureSimTime: 0, lat: 51, lon: 9 },
],
contracts: [
{ id: 'c1', originIcao: 'EDDF', originName: 'F', destIcao: 'EDDV', destName: 'H', reward: 1, cargoType: 'mail', cargoCount: 1, deadlineSimTime: 9e8, status: 'accepted', planeId: 'p1', createdSimTime: 0 },
],
}),
AIRPORTS,
);
expect(fc.features).toHaveLength(1);
expect(fc.features[0].properties.kind).toBe('inflight');
});
it('accepted + planeId set but plane grounded → still pending (defensive)', () => {
const fc = routesGeoJson(
snapshot({
planes: [
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', contractId: null, departureSimTime: null, lat: 50.03, lon: 8.56 },
],
contracts: [
{ id: 'c1', originIcao: 'EDDF', originName: 'F', destIcao: 'EDDV', destName: 'H', reward: 1, cargoType: 'mail', cargoCount: 1, deadlineSimTime: 9e8, status: 'accepted', planeId: 'p1', createdSimTime: 0 },
],
}),
AIRPORTS,
);
expect(fc.features[0].properties.kind).toBe('pending');
});
it('skips a contract whose endpoint is missing from the airport dataset', () => {
const fc = routesGeoJson(
snapshot({
contracts: [
{ id: 'c1', originIcao: 'ZZZZ', originName: '?', destIcao: 'EDDV', destName: 'H', reward: 1, cargoType: 'mail', cargoCount: 1, deadlineSimTime: 9e8, status: 'accepted', planeId: null, createdSimTime: 0 },
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 0, lon: 0 },
],
}),
AIRPORTS,
);
expect(fc.features).toHaveLength(0);
});
it('one line per flying plane — multiple planes draw multiple legs', () => {
const fc = routesGeoJson(
snapshot({
planes: [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 51, lon: 9 },
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 52.4, lon: 9.6 },
],
}),
AIRPORTS,
);
expect(fc.features.map((f) => f.properties.planeId).sort()).toEqual(['p1', 'p2']);
});
});