feat: v0.8.0 — player-actions API: accept/dispatch/buy/sell/refuel endpoints (tick-first, full-snapshot responses), spawn coverage rule, test create-contract hook

This commit is contained in:
2026-09-17 13:54:13 +00:00
parent 96835e935d
commit aa70eb5286
25 changed files with 1843 additions and 8 deletions
+84
View File
@@ -15,6 +15,7 @@ import {
finalizeArrivals,
MAX_TICK_ELAPSED_MS,
reputationGainFor,
spawnCoverageContracts,
spawnOpenContracts,
tickOnce,
} from './engine';
@@ -257,6 +258,89 @@ describe('spawnOpenContracts', () => {
});
});
describe('spawnCoverageContracts (v0.8.0 spawn coverage rule)', () => {
// The seeded starter plane is grounded at EDDF with no seeded contract from
// there, so a fresh DB always has exactly one uncovered airport: EDDF.
it('spawns an open contract originating at the airport of each uncovered grounded plane', async () => {
const inst = await freshDb('coverage.db');
const clock = T0 + 5_000;
// Wipe all seeded contracts so coverage is the only source of new ones.
for (const c of await inst.db.select().from(schema.contracts)) {
await inst.db.delete(schema.contracts).where(eq(schema.contracts.id, c.id));
}
const n = await spawnCoverageContracts(inst, clock, mulberry32(7));
expect(n).toBe(1); // only EDDF is uncovered (single grounded plane)
const openRows = (await inst.db.select().from(schema.contracts)).filter((c) => c.status === 'open');
expect(openRows.length).toBe(1);
expect(openRows[0].originIcao).toBe('EDDF'); // origin forced to the plane's airport
expect(openRows[0].createdSimTime).toBe(clock);
});
it('does nothing when every grounded airport already has an open contract', async () => {
const inst = await freshDb('coverage-covered.db');
for (const c of await inst.db.select().from(schema.contracts)) {
await inst.db.delete(schema.contracts).where(eq(schema.contracts.id, c.id));
}
// One open contract from EDDF covers the starter plane's airport.
await inst.db.insert(schema.contracts).values({
id: 'c-cover', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
deadlineSimTime: T0 + 3_600_000, status: 'open', planeId: null, createdSimTime: T0,
});
expect(await spawnCoverageContracts(inst, T0 + 5_000, mulberry32(7))).toBe(0);
expect((await inst.db.select().from(schema.contracts)).length).toBe(1); // nothing added
});
it('spawns one contract per uncovered airport when planes sit at several airports', async () => {
const inst = await freshDb('coverage-multi.db');
for (const c of await inst.db.select().from(schema.contracts)) {
await inst.db.delete(schema.contracts).where(eq(schema.contracts.id, c.id));
}
// A second grounded plane at LEMD; two planes sharing EDDF must not double-spawn.
await inst.db.insert(schema.planes).values([
{ id: 'p-2', model: 'atr72', status: 'available', fuel: 100, maxFuel: 6300, locationIcao: 'LEMD', contractId: null, lat: 40.4, lon: -3.6, departureSimTime: null },
{ id: 'p-3', model: 'atr72', status: 'available', fuel: 100, maxFuel: 6300, locationIcao: 'EDDF', contractId: null, lat: 50.03, lon: 8.57, departureSimTime: null },
]);
const n = await spawnCoverageContracts(inst, T0 + 5_000, mulberry32(7));
expect(n).toBe(2); // EDDF (×2 planes → one contract) and LEMD
const origins = (await inst.db.select().from(schema.contracts))
.filter((c) => c.status === 'open')
.map((c) => c.originIcao)
.sort();
expect(origins).toEqual(['EDDF', 'LEMD']);
});
it('ignores flying planes — they cannot take new work until they land', async () => {
const inst = await freshDb('coverage-flying.db');
for (const c of await inst.db.select().from(schema.contracts)) {
await inst.db.delete(schema.contracts).where(eq(schema.contracts.id, c.id));
}
await inst.db.update(schema.planes)
.set({ status: 'flying', departureSimTime: T0, contractId: 'c-gone' })
.where(eq(schema.planes.id, 'plane-starter'));
expect(await spawnCoverageContracts(inst, T0 + 5_000, mulberry32(7))).toBe(0); // no grounded planes
});
it('runs inside tickOnce: a fresh DB gains an EDDF-origin contract after one advancing tick', async () => {
const inst = await freshDb('coverage-tick.db');
vi.setSystemTime(T0 + 1_000); // one real second → advanceMs > 0 at scale 1
await tickOnce(inst);
const fromEdff = (await inst.db.select().from(schema.contracts)).filter(
(c) => c.status === 'open' && c.originIcao === 'EDDF',
);
expect(fromEdff.length).toBeGreaterThanOrEqual(1); // coverage held through the full tick pipeline
});
});
// Local helper mirroring lib/sim/movement.ts (kept private there on purpose — the
// engine composes arrivalTimeFor; tests just need the duration for fixtures).
function travelMs(km: number, speedKmh: number): number {