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:
+135
-102
@@ -8,15 +8,15 @@ import * as schema from '@/lib/db/schema';
|
||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||
import { haversineKm } from '@/lib/geo';
|
||||
import { PLANE_TYPES } from '@/lib/game/catalog';
|
||||
import { MARKET_TARGET_OPEN_CONTRACTS, mulberry32 } from '@/lib/game/seed';
|
||||
import { MARKET_TARGET_OPEN_PACKAGES, mulberry32 } from '@/lib/game/seed';
|
||||
import { arrivalTimeFor } from './movement';
|
||||
import {
|
||||
expireContracts,
|
||||
finalizeArrivals,
|
||||
deliverPackages,
|
||||
expirePackages,
|
||||
MAX_TICK_ELAPSED_MS,
|
||||
reputationGainFor,
|
||||
spawnCoverageContracts,
|
||||
spawnOpenContracts,
|
||||
spawnCoveragePackages,
|
||||
spawnOpenPackages,
|
||||
tickOnce,
|
||||
} from './engine';
|
||||
|
||||
@@ -91,37 +91,41 @@ describe('tickOnce — clock advance', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('expireContracts', () => {
|
||||
it('flips only open contracts past their deadline; accepted ones never expire', async () => {
|
||||
describe('expirePackages', () => {
|
||||
it('flips open AND loaded packages past their deadline; terminal statuses never expire', async () => {
|
||||
const inst = await freshDb('expiry.db');
|
||||
const clock = T0 + 100_000;
|
||||
|
||||
await inst.db.insert(schema.contracts).values([
|
||||
{ id: 'c-open-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'open', planeId: null, createdSimTime: T0 },
|
||||
{ id: 'c-open-future', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock + 1, status: 'open', planeId: null, createdSimTime: T0 },
|
||||
{ id: 'c-accepted-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'accepted', planeId: null, createdSimTime: T0 },
|
||||
await inst.db.insert(schema.packages).values([
|
||||
{ id: 'p-open-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'open', planeId: null, createdSimTime: T0 },
|
||||
{ id: 'p-open-future', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock + 1, status: 'open', planeId: null, createdSimTime: T0 },
|
||||
{ id: 'p-loaded-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
|
||||
{ id: 'p-delivered-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'delivered', planeId: null, createdSimTime: T0 },
|
||||
{ id: 'p-abandoned-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'abandoned', planeId: null, createdSimTime: T0 },
|
||||
]);
|
||||
|
||||
const n = await expireContracts(inst, clock);
|
||||
expect(n).toBe(1);
|
||||
const n = await expirePackages(inst, clock);
|
||||
expect(n).toBe(2); // the open and the loaded one
|
||||
|
||||
const byId = new Map((await inst.db.select().from(schema.contracts)).map((c) => [c.id, c.status]));
|
||||
expect(byId.get('c-open-past')).toBe('expired');
|
||||
expect(byId.get('c-open-future')).toBe('open'); // deadline not reached yet
|
||||
expect(byId.get('c-accepted-past')).toBe('accepted'); // accepted contracts complete, never expire
|
||||
const byId = new Map((await inst.db.select().from(schema.packages)).map((p) => [p.id, p.status]));
|
||||
expect(byId.get('p-open-past')).toBe('expired');
|
||||
expect(byId.get('p-open-future')).toBe('open'); // deadline not reached yet
|
||||
expect(byId.get('p-loaded-past')).toBe('expired'); // v0.12.0: loaded packages expire too (keep their slot)
|
||||
expect(byId.get('p-delivered-past')).toBe('delivered'); // terminal — never flips
|
||||
expect(byId.get('p-abandoned-past')).toBe('abandoned'); // terminal — never flips
|
||||
});
|
||||
|
||||
it('a deadline exactly at the clock counts as expired', async () => {
|
||||
const inst = await freshDb('expiry-edge.db');
|
||||
await inst.db.insert(schema.contracts).values({
|
||||
id: 'c-edge', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
|
||||
await inst.db.insert(schema.packages).values({
|
||||
id: 'p-edge', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
|
||||
deadlineSimTime: T0 + 50_000, status: 'open', planeId: null, createdSimTime: T0,
|
||||
});
|
||||
expect(await expireContracts(inst, T0 + 50_000)).toBe(1); // <= is inclusive
|
||||
expect(await expirePackages(inst, T0 + 50_000)).toBe(1); // <= is inclusive
|
||||
});
|
||||
});
|
||||
|
||||
describe('finalizeArrivals', () => {
|
||||
describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
||||
const FROM = 'EDDF';
|
||||
const TO = 'LEMD';
|
||||
|
||||
@@ -133,25 +137,21 @@ describe('finalizeArrivals', () => {
|
||||
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
||||
const type = PLANE_TYPES.atr72;
|
||||
|
||||
// A dispatched flight: contract accepted + bound to the plane, plane flying
|
||||
// with its fuel frozen at departure (the v0.8.0 dispatch step writes these).
|
||||
await inst.db.insert(schema.contracts).values({
|
||||
id: 'c-flight', originIcao: FROM, destIcao: TO, reward: 5_000, cargoType: 'Passengers',
|
||||
cargoCount: 60, deadlineSimTime: T0 + 24 * 3_600_000, status: 'accepted', planeId: 'plane-starter', createdSimTime: T0,
|
||||
});
|
||||
// A manual flight (v0.12.0 fly): plane flying with dest_icao set and its fuel
|
||||
// frozen at departure — no contract involved anymore.
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ status: 'flying', contractId: 'c-flight', departureSimTime: T0, fuel: type.maxFuel })
|
||||
.set({ status: 'flying', destIcao: TO, departureSimTime: T0, fuel: type.maxFuel })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
return { inst, from, to, km, type };
|
||||
}
|
||||
|
||||
it('does nothing before the arrival time', async () => {
|
||||
const { inst, km, type } = await setupFlight('finalize-early.db');
|
||||
const { inst, km, type } = await setupFlight('deliver-early.db');
|
||||
const accountBefore = (await inst.db.select().from(schema.account))[0]!;
|
||||
|
||||
const clock = T0 + travelMs(km, type.speedKmh) - 1; // one ms before arrival
|
||||
expect(await finalizeArrivals(inst, clock)).toBe(0);
|
||||
expect(await deliverPackages(inst, clock)).toBe(0);
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
const account = (await inst.db.select().from(schema.account))[0]!;
|
||||
@@ -159,12 +159,20 @@ describe('finalizeArrivals', () => {
|
||||
expect(account.cash).toBe(accountBefore.cash); // no award before landing
|
||||
});
|
||||
|
||||
it('awards cash + reputation exactly once and grounds the plane at the destination', async () => {
|
||||
const { inst, to, km, type } = await setupFlight('finalize-late.db');
|
||||
it('delivers in-deadline cargo, unbinds expired cargo for free, keeps other-dest cargo bound', async () => {
|
||||
const { inst, to, km, type } = await setupFlight('deliver-mixed.db');
|
||||
const accountBefore = (await inst.db.select().from(schema.account))[0]!;
|
||||
|
||||
// Three packages on the plane: one in-deadline for TO, one expired for TO,
|
||||
// one loaded for a DIFFERENT airport (stays bound for a later leg).
|
||||
await inst.db.insert(schema.packages).values([
|
||||
{ id: 'p-pay', originIcao: FROM, destIcao: TO, reward: 5_000, cargoType: 'Passengers', cargoCount: 60, deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
|
||||
{ id: 'p-late', originIcao: FROM, destIcao: TO, reward: 7_000, cargoType: 'Mail', cargoCount: 10, deadlineSimTime: T0 + 3_600_000, status: 'expired', planeId: 'plane-starter', createdSimTime: T0 },
|
||||
{ id: 'p-elsewhere', originIcao: FROM, destIcao: 'EHAM', reward: 9_000, cargoType: 'Mail', cargoCount: 10, deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
|
||||
]);
|
||||
|
||||
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
|
||||
expect(await finalizeArrivals(inst, clock)).toBe(1);
|
||||
expect(await deliverPackages(inst, clock)).toBe(1);
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('available');
|
||||
@@ -173,65 +181,90 @@ describe('finalizeArrivals', () => {
|
||||
expect(plane.lon).toBe(to.lon);
|
||||
expect(plane.fuel).toBeCloseTo(Math.max(0, type.maxFuel - type.burnPerKm * km), 6); // remaining fuel
|
||||
expect(plane.departureSimTime).toBeNull();
|
||||
expect(plane.destIcao).toBeNull(); // flight context cleared on landing
|
||||
|
||||
const contract = (await inst.db.select().from(schema.contracts)).find((c) => c.id === 'c-flight')!;
|
||||
expect(contract.status).toBe('completed');
|
||||
const byId = new Map((await inst.db.select().from(schema.packages)).map((p) => [p.id, p]));
|
||||
expect(byId.get('p-pay')!.status).toBe('delivered');
|
||||
expect(byId.get('p-late')!.status).toBe('expired'); // still expired — just unbound…
|
||||
expect(byId.get('p-late')!.planeId).toBeNull(); // …slot freed, no reward
|
||||
expect(byId.get('p-elsewhere')!.status).toBe('loaded'); // not this airport's cargo…
|
||||
expect(byId.get('p-elsewhere')!.planeId).toBe('plane-starter'); // …stays bound for the next leg
|
||||
|
||||
const account = (await inst.db.select().from(schema.account))[0]!;
|
||||
expect(account.cash).toBeCloseTo(accountBefore.cash + 5_000, 9); // exact reward
|
||||
expect(account.cash).toBeCloseTo(accountBefore.cash + 5_000, 9); // ONLY p-pay paid out
|
||||
expect(account.reputation).toBeCloseTo(
|
||||
Math.round((accountBefore.reputation + reputationGainFor(km)) * 100) / 100,
|
||||
9,
|
||||
);
|
||||
|
||||
// A later tick must not award again — the plane is no longer 'flying'.
|
||||
expect(await finalizeArrivals(inst, clock + 3_600_000)).toBe(0);
|
||||
expect(await deliverPackages(inst, clock + 3_600_000)).toBe(0);
|
||||
const accountAfter = (await inst.db.select().from(schema.account))[0]!;
|
||||
expect(accountAfter.cash).toBe(account.cash);
|
||||
expect(accountAfter.reputation).toBe(account.reputation);
|
||||
});
|
||||
|
||||
it('delivers every in-deadline package for the arrival airport with per-package rewards', async () => {
|
||||
const { inst, km } = await setupFlight('deliver-multi.db');
|
||||
const accountBefore = (await inst.db.select().from(schema.account))[0]!;
|
||||
|
||||
await inst.db.insert(schema.packages).values([
|
||||
{ id: 'p-a', originIcao: FROM, destIcao: TO, reward: 1_000, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
|
||||
{ id: 'p-b', originIcao: FROM, destIcao: TO, reward: 2_500, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
|
||||
]);
|
||||
|
||||
const clock = T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh);
|
||||
expect(await deliverPackages(inst, clock)).toBe(1);
|
||||
|
||||
const account = (await inst.db.select().from(schema.account))[0]!;
|
||||
expect(account.cash).toBeCloseTo(accountBefore.cash + 3_500, 9); // both rewards
|
||||
expect(account.reputation).toBeCloseTo(
|
||||
Math.round((accountBefore.reputation + reputationGainFor(km) * 2) * 100) / 100,
|
||||
9,
|
||||
); // rep per delivered package
|
||||
});
|
||||
|
||||
it('leaves planes without route info alone', async () => {
|
||||
const inst = await freshDb('finalize-noroute.db');
|
||||
// Flying plane whose contract row is missing — defensive skip, no crash.
|
||||
const inst = await freshDb('deliver-noroute.db');
|
||||
// Flying plane whose dest_icao is missing — defensive skip, no crash.
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ status: 'flying', departureSimTime: T0, contractId: 'c-missing' });
|
||||
expect(await finalizeArrivals(inst, T0 + 3_600_000)).toBe(0);
|
||||
.set({ status: 'flying', departureSimTime: T0 });
|
||||
expect(await deliverPackages(inst, T0 + 3_600_000)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('spawnOpenContracts', () => {
|
||||
it('tops the market up to MARKET_TARGET_OPEN_CONTRACTS using the shared seed builder', async () => {
|
||||
describe('spawnOpenPackages', () => {
|
||||
it('tops the market up to MARKET_TARGET_OPEN_PACKAGES using the shared seed builder', async () => {
|
||||
const inst = await freshDb('spawn.db');
|
||||
const clock = T0 + 123_456;
|
||||
|
||||
// Leave exactly 3 open contracts (the rest of the seeded set is "done").
|
||||
const all = await inst.db.select().from(schema.contracts);
|
||||
for (const c of all.slice(3)) {
|
||||
await inst.db.update(schema.contracts).set({ status: 'completed' }).where(eq(schema.contracts.id, c.id));
|
||||
// Leave exactly 3 open packages (the rest of the seeded set is "done").
|
||||
const all = await inst.db.select().from(schema.packages);
|
||||
for (const p of all.slice(3)) {
|
||||
await inst.db.update(schema.packages).set({ status: 'delivered' }).where(eq(schema.packages.id, p.id));
|
||||
}
|
||||
|
||||
const needed = await spawnOpenContracts(inst, clock, mulberry32(7));
|
||||
expect(needed).toBe(MARKET_TARGET_OPEN_CONTRACTS - 3);
|
||||
const needed = await spawnOpenPackages(inst, clock, mulberry32(7));
|
||||
expect(needed).toBe(MARKET_TARGET_OPEN_PACKAGES - 3);
|
||||
|
||||
const openRows = (await inst.db.select().from(schema.contracts)).filter((c) => c.status === 'open');
|
||||
expect(openRows.length).toBe(MARKET_TARGET_OPEN_CONTRACTS);
|
||||
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.status === 'open');
|
||||
expect(openRows.length).toBe(MARKET_TARGET_OPEN_PACKAGES);
|
||||
|
||||
const fc = await loadAirportsGeoJson();
|
||||
const icaoSet = new Set(fc.features.map((f) => f.properties.icao));
|
||||
for (const c of openRows.slice(3)) { // the spawned ones
|
||||
expect(c.createdSimTime).toBe(clock); // created at game time, not real time
|
||||
expect(icaoSet.has(c.originIcao)).toBe(true);
|
||||
expect(icaoSet.has(c.destIcao)).toBe(true);
|
||||
expect(c.originIcao).not.toBe(c.destIcao);
|
||||
expect(c.planeId).toBeNull();
|
||||
for (const p of openRows.slice(3)) { // the spawned ones
|
||||
expect(p.createdSimTime).toBe(clock); // created at game time, not real time
|
||||
expect(icaoSet.has(p.originIcao)).toBe(true);
|
||||
expect(icaoSet.has(p.destIcao)).toBe(true);
|
||||
expect(p.originIcao).not.toBe(p.destIcao);
|
||||
expect(p.planeId).toBeNull();
|
||||
// Same deadline window as the seed: now + 6–72h game time.
|
||||
expect(c.deadlineSimTime).toBeGreaterThan(clock + 6 * 3_600_000 - 1);
|
||||
expect(c.deadlineSimTime).toBeLessThanOrEqual(clock + 72 * 3_600_000);
|
||||
expect(p.deadlineSimTime).toBeGreaterThan(clock + 6 * 3_600_000 - 1);
|
||||
expect(p.deadlineSimTime).toBeLessThanOrEqual(clock + 72 * 3_600_000);
|
||||
}
|
||||
|
||||
// Already at target → nothing more to spawn.
|
||||
expect(await spawnOpenContracts(inst, clock, mulberry32(7))).toBe(0);
|
||||
expect(await spawnOpenPackages(inst, clock, mulberry32(7))).toBe(0);
|
||||
});
|
||||
|
||||
it('is deterministic for an injected RNG (spawn matches the seed builder)', async () => {
|
||||
@@ -239,103 +272,103 @@ describe('spawnOpenContracts', () => {
|
||||
const b = await freshDb('spawn-b.db');
|
||||
const clock = T0 + 999;
|
||||
|
||||
// Wipe all contracts in both so both spawn from zero with the same RNG.
|
||||
// Wipe all packages in both so both spawn from zero with the same RNG.
|
||||
for (const inst of [a, b]) {
|
||||
const rows = await inst.db.select().from(schema.contracts);
|
||||
const rows = await inst.db.select().from(schema.packages);
|
||||
for (const r of rows) {
|
||||
await inst.db.delete(schema.contracts).where(eq(schema.contracts.id, r.id));
|
||||
await inst.db.delete(schema.packages).where(eq(schema.packages.id, r.id));
|
||||
}
|
||||
}
|
||||
|
||||
expect(await spawnOpenContracts(a, clock, mulberry32(42))).toBe(MARKET_TARGET_OPEN_CONTRACTS);
|
||||
expect(await spawnOpenContracts(b, clock, mulberry32(42))).toBe(MARKET_TARGET_OPEN_CONTRACTS);
|
||||
expect(await spawnOpenPackages(a, clock, mulberry32(42))).toBe(MARKET_TARGET_OPEN_PACKAGES);
|
||||
expect(await spawnOpenPackages(b, clock, mulberry32(42))).toBe(MARKET_TARGET_OPEN_PACKAGES);
|
||||
|
||||
const strip = (rows: schema.ContractRow[]) =>
|
||||
rows.filter((c) => c.status === 'open').map(({ id: _id, ...rest }) => JSON.stringify(rest)).sort();
|
||||
expect(strip(await a.db.select().from(schema.contracts))).toEqual(
|
||||
strip(await b.db.select().from(schema.contracts)),
|
||||
const strip = (rows: schema.PackageRow[]) =>
|
||||
rows.filter((p) => p.status === 'open').map(({ id: _id, ...rest }) => JSON.stringify(rest)).sort();
|
||||
expect(strip(await a.db.select().from(schema.packages))).toEqual(
|
||||
strip(await b.db.select().from(schema.packages)),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('spawnCoverageContracts (v0.8.0 spawn coverage rule)', () => {
|
||||
// The seeded starter plane is grounded at EDDF with no seeded contract from
|
||||
describe('spawnCoveragePackages (v0.8.0 spawn coverage rule)', () => {
|
||||
// The seeded starter plane is grounded at EDDF with no seeded package 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 () => {
|
||||
it('spawns an open package 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));
|
||||
// Wipe all seeded packages so coverage is the only source of new ones.
|
||||
for (const p of await inst.db.select().from(schema.packages)) {
|
||||
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
|
||||
}
|
||||
|
||||
const n = await spawnCoverageContracts(inst, clock, mulberry32(7));
|
||||
const n = await spawnCoveragePackages(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');
|
||||
const openRows = (await inst.db.select().from(schema.packages)).filter((p) => p.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 () => {
|
||||
it('does nothing when every grounded airport already has an open package', 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));
|
||||
for (const p of await inst.db.select().from(schema.packages)) {
|
||||
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.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,
|
||||
// One open package from EDDF covers the starter plane's airport.
|
||||
await inst.db.insert(schema.packages).values({
|
||||
id: 'p-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
|
||||
expect(await spawnCoveragePackages(inst, T0 + 5_000, mulberry32(7))).toBe(0);
|
||||
expect((await inst.db.select().from(schema.packages)).length).toBe(1); // nothing added
|
||||
});
|
||||
|
||||
it('spawns one contract per uncovered airport when planes sit at several airports', async () => {
|
||||
it('spawns one package 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));
|
||||
for (const p of await inst.db.select().from(schema.packages)) {
|
||||
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.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 },
|
||||
{ id: 'p-2', model: 'atr72', status: 'available', fuel: 100, maxFuel: 6300, locationIcao: 'LEMD', destIcao: null, lat: 40.4, lon: -3.6, departureSimTime: null },
|
||||
{ id: 'p-3', model: 'atr72', status: 'available', fuel: 100, maxFuel: 6300, locationIcao: 'EDDF', destIcao: 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 n = await spawnCoveragePackages(inst, T0 + 5_000, mulberry32(7));
|
||||
expect(n).toBe(2); // EDDF (×2 planes → one package) and LEMD
|
||||
|
||||
const origins = (await inst.db.select().from(schema.contracts))
|
||||
.filter((c) => c.status === 'open')
|
||||
.map((c) => c.originIcao)
|
||||
const origins = (await inst.db.select().from(schema.packages))
|
||||
.filter((p) => p.status === 'open')
|
||||
.map((p) => p.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));
|
||||
for (const p of await inst.db.select().from(schema.packages)) {
|
||||
await inst.db.delete(schema.packages).where(eq(schema.packages.id, p.id));
|
||||
}
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ status: 'flying', departureSimTime: T0, contractId: 'c-gone' })
|
||||
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
expect(await spawnCoverageContracts(inst, T0 + 5_000, mulberry32(7))).toBe(0); // no grounded planes
|
||||
expect(await spawnCoveragePackages(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 () => {
|
||||
it('runs inside tickOnce: a fresh DB gains an EDDF-origin package 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',
|
||||
const fromEdff = (await inst.db.select().from(schema.packages)).filter(
|
||||
(p) => p.status === 'open' && p.originIcao === 'EDDF',
|
||||
);
|
||||
expect(fromEdff.length).toBeGreaterThanOrEqual(1); // coverage held through the full tick pipeline
|
||||
});
|
||||
|
||||
+66
-54
@@ -1,13 +1,13 @@
|
||||
import { randomInt, randomUUID } from 'node:crypto';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { getGameDb, persist, type GameDb } from '@/lib/db/client';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||
import { haversineKm } from '@/lib/geo';
|
||||
import { PLANE_TYPES } from '@/lib/game/catalog';
|
||||
import {
|
||||
generateContract,
|
||||
MARKET_TARGET_OPEN_CONTRACTS,
|
||||
generatePackage,
|
||||
MARKET_TARGET_OPEN_PACKAGES,
|
||||
mulberry32,
|
||||
type Rng,
|
||||
type SeedAirport,
|
||||
@@ -29,7 +29,7 @@ export const MAX_TICK_ELAPSED_MS = 2_000;
|
||||
export const MIN_TIME_SCALE = 1;
|
||||
export const MAX_TIME_SCALE = 64; // top of the power-of-two ladder (v0.9.2)
|
||||
|
||||
// Reputation gain per completed contract: BASE + PER_KM × great-circle km, rounded
|
||||
// Reputation gain per delivered package: BASE + PER_KM × great-circle km, rounded
|
||||
// to 2 decimals (reputation is REAL). A short hop (~300 km) earns ~1.15 rep, a
|
||||
// cross-Europe leg (~3000 km) ~2.5 — slow growth from the starting 10.
|
||||
export const REP_BASE = 1;
|
||||
@@ -72,12 +72,12 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
|
||||
const clockMs = Math.round(sim.clockMs + advanceMs);
|
||||
|
||||
if (advanceMs > 0) {
|
||||
await expireContracts(inst, clockMs);
|
||||
await finalizeArrivals(inst, clockMs);
|
||||
await spawnOpenContracts(inst, clockMs);
|
||||
// AFTER finalization on purpose: a plane that just landed gets work spawned
|
||||
await expirePackages(inst, clockMs);
|
||||
await deliverPackages(inst, clockMs);
|
||||
await spawnOpenPackages(inst, clockMs);
|
||||
// AFTER delivery on purpose: a plane that just landed gets work spawned
|
||||
// at its new location within the same tick.
|
||||
await spawnCoverageContracts(inst, clockMs);
|
||||
await spawnCoveragePackages(inst, clockMs);
|
||||
}
|
||||
|
||||
// Persist lastRealTickMs even when nothing advanced — it is the tick anchor.
|
||||
@@ -88,42 +88,45 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
|
||||
return updated;
|
||||
}
|
||||
|
||||
// Open contracts whose deadline has passed become 'expired'. Accepted/in-flight
|
||||
// contracts never expire — they complete. Returns the number flipped.
|
||||
export async function expireContracts(inst: GameDb, clockMs: number): Promise<number> {
|
||||
const open = await inst.db.select().from(schema.contracts).where(eq(schema.contracts.status, 'open'));
|
||||
// Packages whose deadline has passed become 'expired' — both OPEN ones (they leave
|
||||
// the market so spawn can top it back up) and LOADED ones (v0.12.0: they keep
|
||||
// occupying their slot, deliver no reward on arrival, and abandon for free).
|
||||
// Delivered/abandoned packages are terminal and never flip. Returns the count.
|
||||
export async function expirePackages(inst: GameDb, clockMs: number): Promise<number> {
|
||||
const rows = await inst.db.select().from(schema.packages).where(inArray(schema.packages.status, ['open', 'loaded']));
|
||||
let n = 0;
|
||||
for (const c of open) {
|
||||
if (c.deadlineSimTime <= clockMs) {
|
||||
await inst.db.update(schema.contracts).set({ status: 'expired' }).where(eq(schema.contracts.id, c.id));
|
||||
for (const p of rows) {
|
||||
if (p.deadlineSimTime <= clockMs) {
|
||||
await inst.db.update(schema.packages).set({ status: 'expired' }).where(eq(schema.packages.id, p.id));
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// Finalize in-flight planes whose arrival time has passed: award the contract
|
||||
// reward (cash) + reputation gain exactly once, mark the contract completed, and
|
||||
// ground the plane at the destination with its remaining fuel. Idempotent — a
|
||||
// finalized plane leaves the 'flying' state, so it can never be awarded twice.
|
||||
export async function finalizeArrivals(inst: GameDb, clockMs: number): Promise<number> {
|
||||
const [planeRows, contractRows] = await Promise.all([
|
||||
// Finalize in-flight planes whose arrival time has passed (v0.12.0 delivery):
|
||||
// ground the plane at its dest_icao with its remaining fuel, then for every
|
||||
// package bound to it and destined for that airport — 'loaded' ones still inside
|
||||
// their deadline are delivered (cash += reward + reputation gain per package),
|
||||
// 'expired' ones just unbind (no reward). Packages destined elsewhere stay loaded
|
||||
// on the plane for a later leg. Idempotent — a finalized plane leaves the
|
||||
// 'flying' state, so it can never be awarded twice. Returns planes landed.
|
||||
export async function deliverPackages(inst: GameDb, clockMs: number): Promise<number> {
|
||||
const [planeRows, packageRows] = await Promise.all([
|
||||
inst.db.select().from(schema.planes),
|
||||
inst.db.select().from(schema.contracts),
|
||||
inst.db.select().from(schema.packages),
|
||||
]);
|
||||
|
||||
const fc = await loadAirportsGeoJson();
|
||||
const byIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties]));
|
||||
const contractById = new Map(contractRows.map((c) => [c.id, c]));
|
||||
|
||||
let n = 0;
|
||||
for (const p of planeRows) {
|
||||
if (p.status !== 'flying' || p.departureSimTime == null || !p.contractId) continue;
|
||||
const contract = contractById.get(p.contractId);
|
||||
if (p.status !== 'flying' || p.departureSimTime == null || !p.destIcao) continue;
|
||||
const type = PLANE_TYPES[p.model];
|
||||
const from = contract ? byIcao.get(contract.originIcao) : undefined;
|
||||
const to = contract ? byIcao.get(contract.destIcao) : undefined;
|
||||
if (!contract || !type || !from || !to) continue; // no route info — leave alone
|
||||
const from = byIcao.get(p.locationIcao);
|
||||
const to = byIcao.get(p.destIcao);
|
||||
if (!type || !from || !to) continue; // no route info — leave alone
|
||||
|
||||
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
||||
if (clockMs < arrivalTimeFor(p.departureSimTime, distanceKm, type.speedKmh)) continue;
|
||||
@@ -137,35 +140,44 @@ export async function finalizeArrivals(inst: GameDb, clockMs: number): Promise<n
|
||||
lon: to.lon,
|
||||
fuel: fuelAt(p.fuel, type.burnPerKm, distanceKm, 1),
|
||||
departureSimTime: null,
|
||||
destIcao: null,
|
||||
})
|
||||
.where(eq(schema.planes.id, p.id));
|
||||
await inst.db.update(schema.contracts)
|
||||
.set({ status: 'completed' })
|
||||
.where(eq(schema.contracts.id, contract.id));
|
||||
// Fresh account read per award so multiple arrivals in one tick all count.
|
||||
const fresh = (await inst.db.select().from(schema.account))[0];
|
||||
if (!fresh) throw new Error('account row missing');
|
||||
await inst.db.update(schema.account).set({
|
||||
cash: fresh.cash + contract.reward,
|
||||
reputation: Math.round((fresh.reputation + reputationGainFor(distanceKm)) * 100) / 100,
|
||||
});
|
||||
|
||||
// Deliver every bound package whose destination is the airport just reached.
|
||||
const onBoard = packageRows.filter((pk) => pk.planeId === p.id && pk.destIcao === to.icao);
|
||||
for (const pk of onBoard) {
|
||||
if (pk.status === 'loaded' && clockMs <= pk.deadlineSimTime) {
|
||||
await inst.db.update(schema.packages).set({ status: 'delivered' }).where(eq(schema.packages.id, pk.id));
|
||||
// Fresh account read per award so multiple deliveries in one tick all count.
|
||||
const fresh = (await inst.db.select().from(schema.account))[0];
|
||||
if (!fresh) throw new Error('account row missing');
|
||||
await inst.db.update(schema.account).set({
|
||||
cash: fresh.cash + pk.reward,
|
||||
reputation: Math.round((fresh.reputation + reputationGainFor(distanceKm)) * 100) / 100,
|
||||
});
|
||||
} else if (pk.status === 'expired') {
|
||||
// Expired cargo keeps no value — just free the slot.
|
||||
await inst.db.update(schema.packages).set({ planeId: null }).where(eq(schema.packages.id, pk.id));
|
||||
}
|
||||
}
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// Top the market back up to MARKET_TARGET_OPEN_CONTRACTS open contracts using the
|
||||
// SAME builder as the first-run seed (generateContract), so spawned contracts are
|
||||
// Top the market back up to MARKET_TARGET_OPEN_PACKAGES open packages using the
|
||||
// SAME builder as the first-run seed (generatePackage), so spawned packages are
|
||||
// indistinguishable from seeded ones. RNG is injectable for deterministic tests.
|
||||
export async function spawnOpenContracts(
|
||||
export async function spawnOpenPackages(
|
||||
inst: GameDb,
|
||||
clockMs: number,
|
||||
rng?: Rng,
|
||||
): Promise<number> {
|
||||
const openCount = (
|
||||
await inst.db.select().from(schema.contracts).where(eq(schema.contracts.status, 'open'))
|
||||
await inst.db.select().from(schema.packages).where(eq(schema.packages.status, 'open'))
|
||||
).length;
|
||||
const needed = MARKET_TARGET_OPEN_CONTRACTS - openCount;
|
||||
const needed = MARKET_TARGET_OPEN_PACKAGES - openCount;
|
||||
if (needed <= 0) return 0;
|
||||
|
||||
const fc = await loadAirportsGeoJson();
|
||||
@@ -178,27 +190,27 @@ export async function spawnOpenContracts(
|
||||
const rand = rng ?? mulberry32(randomInt(0, 2 ** 31));
|
||||
|
||||
for (let i = 0; i < needed; i++) {
|
||||
await inst.db.insert(schema.contracts).values(generateContract({ id: randomUUID(), airports, rng: rand, nowMs: clockMs }));
|
||||
await inst.db.insert(schema.packages).values(generatePackage({ id: randomUUID(), airports, rng: rand, nowMs: clockMs }));
|
||||
}
|
||||
return needed;
|
||||
}
|
||||
|
||||
// Spawn coverage rule (v0.8.0): in addition to topping the market up, every
|
||||
// airport that currently has a grounded 'available' plane must have ≥1 OPEN
|
||||
// contract originating there — so work is always available where the fleet sits
|
||||
// (including onboarding at EDDF). One contract per uncovered airport, generated
|
||||
// with generateContract and origin forced to that airport. Runs after
|
||||
// spawnOpenContracts in tickOnce, so randomly spawned contracts count as coverage
|
||||
// too; and after finalizeArrivals, so a plane that just landed gets work spawned
|
||||
// package originating there — so work is always available where the fleet sits
|
||||
// (including onboarding at EDDF). One package per uncovered airport, generated
|
||||
// with generatePackage and origin forced to that airport. Runs after
|
||||
// spawnOpenPackages in tickOnce, so randomly spawned packages count as coverage
|
||||
// too; and after deliverPackages, so a plane that just landed gets work spawned
|
||||
// at its new location within the same tick. Returns the number created.
|
||||
export async function spawnCoverageContracts(
|
||||
export async function spawnCoveragePackages(
|
||||
inst: GameDb,
|
||||
clockMs: number,
|
||||
rng?: Rng,
|
||||
): Promise<number> {
|
||||
const [planeRows, openRows] = await Promise.all([
|
||||
inst.db.select().from(schema.planes),
|
||||
inst.db.select().from(schema.contracts).where(eq(schema.contracts.status, 'open')),
|
||||
inst.db.select().from(schema.packages).where(eq(schema.packages.status, 'open')),
|
||||
]);
|
||||
|
||||
// Distinct airports with at least one grounded plane (flying planes don't count —
|
||||
@@ -224,8 +236,8 @@ export async function spawnCoverageContracts(
|
||||
let n = 0;
|
||||
for (const icao of missing) {
|
||||
if (!airports.some((a) => a.icao === icao)) continue; // defensive — the dataset is the source of all ICACs
|
||||
await inst.db.insert(schema.contracts).values(
|
||||
generateContract({ id: randomUUID(), airports, rng: rand, nowMs: clockMs, originIcao: icao }),
|
||||
await inst.db.insert(schema.packages).values(
|
||||
generatePackage({ id: randomUUID(), airports, rng: rand, nowMs: clockMs, originIcao: icao }),
|
||||
);
|
||||
n++;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user