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:
@@ -1,26 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { dispatchSelection } from './RightPane';
|
||||
import type { ContractState, PlaneState } from '@/lib/game/state';
|
||||
import { loadEligiblePlanes, slotsUsedFor } from './RightPane';
|
||||
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
|
||||
|
||||
// v0.10.1 — the per-contract dispatch picker rule: a row's effective selection is the user's
|
||||
// explicit pick while it stays eligible (available AND at the contract origin), else NOTHING
|
||||
// (placeholder). The old "auto-track first eligible" fallback made every accepted-contract row
|
||||
// display the same plane, so selections looked shared across contracts.
|
||||
// v0.12.0 — the package-model picker rules: a plane can LOAD a package only while
|
||||
// it is GROUNDED at the package's origin with a free slot (capacity from the
|
||||
// snapshot catalog; expired packages still occupy theirs). The Load button acts
|
||||
// directly on the single eligible plane, or shows a picker when several qualify.
|
||||
|
||||
const contract: ContractState = {
|
||||
id: 'contract-a',
|
||||
originIcao: 'EDDF',
|
||||
originName: 'Frankfurt Main',
|
||||
destIcao: 'EDDM',
|
||||
destName: 'Munich',
|
||||
reward: 1000,
|
||||
cargoType: 'mail',
|
||||
cargoCount: 4,
|
||||
deadlineSimTime: 1_000_000,
|
||||
status: 'accepted',
|
||||
planeId: null,
|
||||
createdSimTime: 0,
|
||||
};
|
||||
const CATALOG = [
|
||||
{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, speedKmh: 510, capacity: 6 },
|
||||
];
|
||||
|
||||
function plane(over: Partial<PlaneState> & Pick<PlaneState, 'id'>): PlaneState {
|
||||
return {
|
||||
@@ -30,7 +19,8 @@ function plane(over: Partial<PlaneState> & Pick<PlaneState, 'id'>): PlaneState {
|
||||
maxFuel: 100,
|
||||
locationIcao: 'EDDF',
|
||||
locationName: 'Frankfurt Main',
|
||||
contractId: null,
|
||||
destIcao: null,
|
||||
destName: null,
|
||||
departureSimTime: null,
|
||||
lat: 50,
|
||||
lon: 8.5,
|
||||
@@ -38,29 +28,93 @@ function plane(over: Partial<PlaneState> & Pick<PlaneState, 'id'>): PlaneState {
|
||||
};
|
||||
}
|
||||
|
||||
describe('dispatchSelection (per-contract dispatch picker rule)', () => {
|
||||
it('defaults to the placeholder — no auto-selection of the first eligible plane', () => {
|
||||
const planes = [plane({ id: 'p1' }), plane({ id: 'p2' })]; // both eligible at EDDF
|
||||
expect(dispatchSelection(planes, contract, null)).toBeNull();
|
||||
function pkg(over: Partial<PackageState> & Pick<PackageState, 'id'>): PackageState {
|
||||
return {
|
||||
originIcao: 'EDDF',
|
||||
originName: 'Frankfurt Main',
|
||||
destIcao: 'LEMD',
|
||||
destName: 'Madrid Barajas',
|
||||
reward: 1000,
|
||||
cargoType: 'mail',
|
||||
cargoCount: 4,
|
||||
deadlineSimTime: 1_000_000,
|
||||
status: 'open',
|
||||
planeId: null,
|
||||
createdSimTime: 0,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function snap(planes: PlaneState[], packages: PackageState[] = []): StateSnapshot {
|
||||
return {
|
||||
account: { cash: 0, reputation: 0 },
|
||||
simState: { clockMs: 0, timeScale: 1, paused: false },
|
||||
planes,
|
||||
packages,
|
||||
catalog: CATALOG,
|
||||
fuelPricePerL: 0.8,
|
||||
sellFraction: 0.5,
|
||||
};
|
||||
}
|
||||
|
||||
describe('slotsUsedFor', () => {
|
||||
it('counts loaded AND expired packages bound to the plane (both occupy a slot)', () => {
|
||||
const s = snap(
|
||||
[plane({ id: 'p1' })],
|
||||
[
|
||||
pkg({ id: 'a', status: 'loaded', planeId: 'p1' }),
|
||||
pkg({ id: 'b', status: 'expired', planeId: 'p1' }),
|
||||
pkg({ id: 'c', status: 'open', planeId: null }), // not on any plane
|
||||
pkg({ id: 'd', status: 'delivered', planeId: 'p1' }), // terminal — slot long freed
|
||||
],
|
||||
);
|
||||
expect(slotsUsedFor(s, 'p1')).toBe(2);
|
||||
});
|
||||
|
||||
it('returns the user pick while it stays eligible (available AND at the origin)', () => {
|
||||
const planes = [plane({ id: 'p1' }), plane({ id: 'p2', locationIcao: 'EDDM' })];
|
||||
expect(dispatchSelection(planes, contract, 'p1')?.id).toBe('p1');
|
||||
it('is 0 for a plane with no bound cargo', () => {
|
||||
expect(slotsUsedFor(snap([plane({ id: 'p1' })]), 'p1')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('drops to the placeholder when a poll makes the pick ineligible (flew away)', () => {
|
||||
const planes = [plane({ id: 'p1', status: 'flying' })];
|
||||
expect(dispatchSelection(planes, contract, 'p1')).toBeNull();
|
||||
describe('loadEligiblePlanes (Load button eligibility)', () => {
|
||||
it('includes only grounded planes at the package origin with a free slot', () => {
|
||||
const s = snap([
|
||||
plane({ id: 'at-origin' }), // eligible
|
||||
plane({ id: 'elsewhere', locationIcao: 'LEMD' }), // wrong airport
|
||||
plane({ id: 'in-flight', status: 'flying', destIcao: 'LEMD', destName: 'Madrid Barajas' }), // flying
|
||||
]);
|
||||
expect(loadEligiblePlanes(s, pkg({ id: 'x' })).map((p) => p.id)).toEqual(['at-origin']);
|
||||
});
|
||||
|
||||
it('drops to the placeholder when a poll moves the pick off the origin', () => {
|
||||
const planes = [plane({ id: 'p1', locationIcao: 'EDDM' })];
|
||||
expect(dispatchSelection(planes, contract, 'p1')).toBeNull();
|
||||
it('excludes a plane whose slots are full (capacity from the catalog)', () => {
|
||||
const packages = Array.from({ length: 6 }, (_, i) =>
|
||||
pkg({ id: `fill-${i}`, status: 'loaded', planeId: 'full' }),
|
||||
);
|
||||
const s = snap([plane({ id: 'full' })], packages);
|
||||
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('unknown selection id resolves to the placeholder', () => {
|
||||
const planes = [plane({ id: 'p1' })];
|
||||
expect(dispatchSelection(planes, contract, 'no-such-plane')).toBeNull();
|
||||
it('a full plane with one slot freed by an expired package still counts as full', () => {
|
||||
const packages = [
|
||||
...Array.from({ length: 5 }, (_, i) => pkg({ id: `fill-${i}`, status: 'loaded', planeId: 'full' })),
|
||||
pkg({ id: 'exp', status: 'expired', planeId: 'full' }), // occupies its slot
|
||||
];
|
||||
const s = snap([plane({ id: 'full' })], packages);
|
||||
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns several planes when several qualify (picker case)', () => {
|
||||
const s = snap([plane({ id: 'a' }), plane({ id: 'b' })]);
|
||||
expect(loadEligiblePlanes(s, pkg({ id: 'x' })).map((p) => p.id).sort()).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('returns nothing when no plane is at the origin (Load stays disabled)', () => {
|
||||
const s = snap([plane({ id: 'far', locationIcao: 'LEMD' })]);
|
||||
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('a plane with an unknown model has no capacity → never eligible', () => {
|
||||
const s = snap([plane({ id: 'mystery', model: 'mystery' })]);
|
||||
expect(loadEligiblePlanes(s, pkg({ id: 'x' }))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user