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
+290
View File
@@ -0,0 +1,290 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { openGameDb, type GameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports';
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog';
import { HUB_ICAO } from './seed';
import {
acceptContract,
buyPlane,
dispatchPlane,
GameActionError,
refuelPlane,
sellPlane,
} from './actions';
// Action tests run against throwaway DB files (openGameDb) with fake timers so
// the seeded sim clock is a known constant. Actions are called directly (no tick —
// engine.runAction() wraps them in production; its lock/tick behavior is covered
// by the route tests and e2e).
let dir: string;
const T0 = 1_750_000_000_000; // seed moment / fake "now" at DB open
beforeAll(async () => {
vi.useFakeTimers();
vi.setSystemTime(T0);
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-actions-'));
});
afterAll(async () => {
vi.useRealTimers();
await fs.rm(dir, { recursive: true, force: true });
});
async function freshDb(name: string): Promise<GameDb> {
vi.setSystemTime(T0); // each throwaway DB is seeded at the same fake "now"
return openGameDb(path.join(dir, name));
}
function expectActionError(promise: Promise<unknown>, status: number, messagePart?: RegExp) {
return promise.then(
() => {
throw new Error(`expected GameActionError(${status}) but the action succeeded`);
},
(err) => {
expect(err).toBeInstanceOf(GameActionError);
expect((err as GameActionError).status).toBe(status);
if (messagePart) expect((err as GameActionError).message).toMatch(messagePart);
},
);
}
// Insert an accepted contract EDDF → LEMD bound to nothing, for dispatch tests.
async function insertAcceptedContract(inst: GameDb, id = 'c-acc'): Promise<void> {
await inst.db.insert(schema.contracts).values({
id,
originIcao: HUB_ICAO,
destIcao: 'LEMD',
reward: 5_000,
cargoType: 'Passengers',
cargoCount: 60,
deadlineSimTime: T0 + 24 * 3_600_000,
status: 'accepted',
planeId: null,
createdSimTime: T0,
});
}
describe('acceptContract', () => {
it('flips an open contract to accepted and persists it', async () => {
const inst = await freshDb('acc-open.db');
await acceptContract(inst, 'contract-1'); // seeded ids are contract-N
const row = (await inst.db.select().from(schema.contracts).where(eq(schema.contracts.id, 'contract-1')))[0];
expect(row.status).toBe('accepted');
});
it.each(['accepted', 'completed', 'expired'] as const)('rejects a %s contract with 409', async (status) => {
const inst = await freshDb(`acc-${status}.db`);
await inst.db.update(schema.contracts).set({ status }).where(eq(schema.contracts.id, 'contract-1'));
await expectActionError(acceptContract(inst, 'contract-1'), 409, new RegExp(status));
});
it('rejects an unknown id with 404', async () => {
const inst = await freshDb('acc-unknown.db');
await expectActionError(acceptContract(inst, 'nope'), 404);
});
});
describe('dispatchPlane', () => {
it('binds the plane: flying + departure at current clock + origin coords, contract bound to plane', async () => {
const inst = await freshDb('disp-ok.db');
await insertAcceptedContract(inst);
await dispatchPlane(inst, 'plane-starter', 'c-acc');
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying');
expect(plane.contractId).toBe('c-acc');
expect(plane.departureSimTime).toBe(T0); // seeded clock, no tick in direct calls
const fc = await loadAirportsGeoJson();
const origin = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
expect(plane.lat).toBe(origin.lat);
expect(plane.lon).toBe(origin.lon);
// Fuel is frozen at the departure value while flying (live fuel is analytic).
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel);
const contract = (await inst.db.select().from(schema.contracts).where(eq(schema.contracts.id, 'c-acc')))[0];
expect(contract.planeId).toBe('plane-starter');
});
it.each(['open', 'completed', 'expired'] as const)('rejects a %s contract with 409 (must be accepted)', async (status) => {
const inst = await freshDb(`disp-c${status}.db`);
await inst.db.insert(schema.contracts).values({
id: 'c-x', originIcao: HUB_ICAO, destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
deadlineSimTime: T0 + 3_600_000, status, planeId: null, createdSimTime: T0,
});
await expectActionError(dispatchPlane(inst, 'plane-starter', 'c-x'), 409, new RegExp(status));
});
it('rejects an unknown contract id with 404', async () => {
const inst = await freshDb('disp-cunknown.db');
await expectActionError(dispatchPlane(inst, 'plane-starter', 'nope'), 404);
});
it('rejects an unknown plane id with 404', async () => {
const inst = await freshDb('disp-punknown.db');
await insertAcceptedContract(inst);
await expectActionError(dispatchPlane(inst, 'ghost', 'c-acc'), 404);
});
it('rejects a flying plane with 409', async () => {
const inst = await freshDb('disp-flying.db');
await insertAcceptedContract(inst);
await inst.db.update(schema.planes)
.set({ status: 'flying', departureSimTime: T0, contractId: 'c-other' })
.where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(dispatchPlane(inst, 'plane-starter', 'c-acc'), 409);
});
it('rejects a plane that is not at the origin with 409', async () => {
const inst = await freshDb('disp-notatorigin.db');
await insertAcceptedContract(inst); // originates at EDDF
await inst.db.update(schema.planes)
.set({ locationIcao: 'LEMD' })
.where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(dispatchPlane(inst, 'plane-starter', 'c-acc'), 409, /not at|at LEMD/);
});
it('rejects a leg the plane cannot fuel with 409 (burn > fuel)', async () => {
const inst = await freshDb('disp-nofuel.db');
await insertAcceptedContract(inst); // EDDF → LEMD ≈ 1950 km × 1.75 L/km ≈ 3400 L
await inst.db.update(schema.planes).set({ fuel: 1 }).where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(dispatchPlane(inst, 'plane-starter', 'c-acc'), 409, /insufficient fuel/i);
});
});
describe('buyPlane', () => {
it('deducts the full price and delivers a full-tank plane at the home base', async () => {
const inst = await freshDb('buy-ok.db');
await inst.db.update(schema.account).set({ cash: 500_000 });
const before = (await inst.db.select().from(schema.planes)).length;
const { planeId } = await buyPlane(inst, 'atr72');
const account = (await inst.db.select().from(schema.account))[0];
expect(account.cash).toBe(500_000 - PLANE_TYPES.atr72.price);
const planes = await inst.db.select().from(schema.planes);
expect(planes.length).toBe(before + 1);
const bought = planes.find((p) => p.id === planeId)!;
expect(bought.model).toBe('atr72');
expect(bought.status).toBe('available');
expect(bought.fuel).toBe(PLANE_TYPES.atr72.maxFuel); // full tank on delivery
expect(bought.maxFuel).toBe(PLANE_TYPES.atr72.maxFuel);
expect(bought.locationIcao).toBe(HUB_ICAO);
const fc = await loadAirportsGeoJson();
const hub = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
expect(bought.lat).toBe(hub.lat);
expect(bought.lon).toBe(hub.lon);
});
it('rejects an unknown catalog model with 400', async () => {
const inst = await freshDb('buy-unknown.db');
await expectActionError(buyPlane(inst, 'boeing747'), 400, /unknown plane model/);
});
it('rejects the purchase when cash is below the price with 409', async () => {
const inst = await freshDb('buy-poor.db'); // seeded cash is 20k < 200k
await expectActionError(buyPlane(inst, 'atr72'), 409, /insufficient funds/i);
});
});
describe('sellPlane', () => {
it('pays out SELL_FRACTION of the catalog price and deletes the row', async () => {
const inst = await freshDb('sell-ok.db');
// A second plane so the starter is not "the last remaining one".
await inst.db.insert(schema.planes).values({
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
locationIcao: HUB_ICAO, contractId: null, lat: 50, lon: 8, departureSimTime: null,
});
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
const { payout } = await sellPlane(inst, 'plane-extra');
expect(payout).toBe(Math.round(PLANE_TYPES.atr72.price * SELL_FRACTION * 100) / 100);
const account = (await inst.db.select().from(schema.account))[0];
expect(account.cash).toBeCloseTo(cashBefore + payout, 6);
const ids = (await inst.db.select().from(schema.planes)).map((p) => p.id);
expect(ids).not.toContain('plane-extra'); // row deleted
expect(ids).toContain('plane-starter');
});
it('rejects selling a flying plane with 409', async () => {
const inst = await freshDb('sell-flying.db');
await inst.db.insert(schema.planes).values({
id: 'plane-extra', model: 'atr72', status: 'flying', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
locationIcao: HUB_ICAO, contractId: null, lat: 50, lon: 8, departureSimTime: T0,
});
await expectActionError(sellPlane(inst, 'plane-extra'), 409, /flying/);
});
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/);
});
it('rejects an unknown id with 404', async () => {
const inst = await freshDb('sell-unknown.db');
await expectActionError(sellPlane(inst, 'ghost'), 404);
});
});
describe('refuelPlane', () => {
it('fills to maxFuel and deducts (maxFuel − fuel) × FUEL_PRICE_PER_L from cash', async () => {
const inst = await freshDb('refuel-ok.db');
await inst.db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter'));
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
const { cost } = await refuelPlane(inst, 'plane-starter');
expect(cost).toBe(Math.round((PLANE_TYPES.atr72.maxFuel - 1_000) * FUEL_PRICE_PER_L * 100) / 100);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel);
const account = (await inst.db.select().from(schema.account))[0];
expect(account.cash).toBeCloseTo(cashBefore - cost, 6);
});
it('is a free no-op when the tank is already full', async () => {
const inst = await freshDb('refuel-full.db'); // seeded starter is full
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
const { cost } = await refuelPlane(inst, 'plane-starter');
expect(cost).toBe(0);
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore);
});
it('rejects a flying plane with 409', async () => {
const inst = await freshDb('refuel-flying.db');
await inst.db.update(schema.planes)
.set({ status: 'flying', departureSimTime: T0, contractId: 'c-x' })
.where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(refuelPlane(inst, 'plane-starter'), 409, /flying/);
});
it('rejects the fill-up when cash is below the cost with 409', async () => {
const inst = await freshDb('refuel-poor.db');
await inst.db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter'));
await inst.db.update(schema.account).set({ cash: 10 }); // cost is ~4960
await expectActionError(refuelPlane(inst, 'plane-starter'), 409, /insufficient funds/i);
});
it('rejects an unknown id with 404', async () => {
const inst = await freshDb('refuel-unknown.db');
await expectActionError(refuelPlane(inst, 'ghost'), 404);
});
});
+215
View File
@@ -0,0 +1,215 @@
import { randomUUID } from 'node:crypto';
import { eq } from 'drizzle-orm';
import * as schema from '@/lib/db/schema';
import { persist, type GameDb } from '@/lib/db/client';
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo';
import { runAction } from '@/lib/sim/engine';
import { getStateSnapshot } from './state';
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog';
import { HUB_ICAO } from './seed';
// Player actions (v0.8.0) — the core loop, server-side: accept a contract,
// dispatch a plane on it, buy/sell planes, refuel. Each function validates and
// mutates ONE throwaway-safe GameDb instance (persisted at the end); routes wrap
// them in engine.runAction() so tick + validate + mutate are atomic under the sim
// lock. Validation failures throw GameActionError carrying the HTTP status:
// 404 — unknown entity id
// 409 — valid ids but the current state forbids the action (wrong contract/plane
// status, plane not at origin, insufficient fuel/funds, last plane)
// 400 — bad input that isn't an existence problem (unknown catalog model)
export class GameActionError extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message);
this.name = 'GameActionError';
}
}
async function airportByIcao(icao: string): Promise<{ icao: string; lat: number; lon: number }> {
const fc = await loadAirportsGeoJson();
const f = fc.features.find((x) => x.properties.icao === icao);
if (!f) throw new Error(`airport ${icao} missing from dataset`); // → 500, cannot happen for seeded data
return { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon };
}
// --- Accept (POST /api/contracts/:id/accept) ---
// open → accepted. Single-player, so no ownership tracking beyond the status flip;
// an accepted contract is claimed by whoever dispatched it first (dispatch checks
// 'accepted' and binds the plane).
export async function acceptContract(inst: GameDb, contractId: string): Promise<void> {
const rows = await inst.db.select().from(schema.contracts).where(eq(schema.contracts.id, contractId));
const contract = rows[0];
if (!contract) throw new GameActionError(404, `contract ${contractId} not found`);
if (contract.status !== 'open') {
throw new GameActionError(409, `contract is ${contract.status}, only open contracts can be accepted`);
}
await inst.db.update(schema.contracts).set({ status: 'accepted' }).where(eq(schema.contracts.id, contractId));
await persist(inst);
}
// --- Dispatch (POST /api/planes/:planeId/dispatch {contractId}) ---
// Bind an accepted contract to a grounded plane at its origin. The v0.7.0
// finalize path then handles the flight: position/fuel are pure functions of the
// clock from departureSimTime, and arrival awards cash + reputation automatically.
export async function dispatchPlane(inst: GameDb, planeId: string, contractId: string): Promise<void> {
const [planeRows, contractRows] = await Promise.all([
inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)),
inst.db.select().from(schema.contracts).where(eq(schema.contracts.id, contractId)),
]);
const contract = contractRows[0];
if (!contract) throw new GameActionError(404, `contract ${contractId} not found`);
if (contract.status !== 'accepted') {
throw new GameActionError(409, `contract is ${contract.status}, it must be accepted before dispatching`);
}
const plane = planeRows[0];
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
if (plane.status !== 'available') {
throw new GameActionError(409, `plane is ${plane.status}, only available planes can be dispatched`);
}
if (plane.locationIcao !== contract.originIcao) {
throw new GameActionError(
409,
`plane is at ${plane.locationIcao} but the contract originates at ${contract.originIcao}`,
);
}
const type = PLANE_TYPES[plane.model];
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
const origin = await airportByIcao(contract.originIcao);
const dest = await airportByIcao(contract.destIcao);
const distanceKm = haversineKm(origin.lat, origin.lon, dest.lat, dest.lon);
const burnL = type.burnPerKm * distanceKm;
if (burnL > plane.fuel) {
throw new GameActionError(409, `insufficient fuel: the leg needs ${Math.round(burnL)} L but the plane has ${Math.round(plane.fuel)} L`);
}
// runAction() ticked first, so this is current game time.
const sim = (await inst.db.select().from(schema.simState))[0];
if (!sim) throw new Error('sim_state row missing');
await inst.db.update(schema.planes)
.set({
status: 'flying',
contractId,
departureSimTime: sim.clockMs,
lat: origin.lat,
lon: origin.lon,
})
.where(eq(schema.planes.id, planeId));
await inst.db.update(schema.contracts).set({ planeId }).where(eq(schema.contracts.id, contractId));
await persist(inst);
}
// --- Market (POST /api/market/buy-plane {model}) ---
// Buy a catalog model at full price; it arrives 'available' with a full tank at
// the home base (EDDF).
export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId: string }> {
const type = PLANE_TYPES[model];
if (!type) throw new GameActionError(400, `unknown plane model "${model}"`);
const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing');
if (account.cash < type.price) {
throw new GameActionError(409, `insufficient funds: a ${type.name} costs ${type.price}, you have ${Math.round(account.cash)}`);
}
const fc = await loadAirportsGeoJson();
// Same hub fallback as the seed (HUB_ICAO, else first airport).
const home = fc.features.find((f) => f.properties.icao === HUB_ICAO)?.properties ?? fc.features[0].properties;
const planeId = randomUUID();
await inst.db.insert(schema.planes).values({
id: planeId,
model,
status: 'available',
fuel: type.maxFuel, // full tank on delivery
maxFuel: type.maxFuel,
locationIcao: home.icao,
contractId: null,
lat: home.lat,
lon: home.lon,
departureSimTime: null,
});
await inst.db.update(schema.account).set({ cash: account.cash - type.price });
await persist(inst);
return { planeId };
}
// --- Market (POST /api/market/sell-plane {planeId}) ---
// Sell a grounded plane for SELL_FRACTION of its catalog price. The last
// remaining plane can never be sold — the company needs at least one aircraft.
export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout: number }> {
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
const plane = rows[0];
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
if (plane.status !== 'available') {
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be sold`);
}
const all = await inst.db.select().from(schema.planes);
if (all.length <= 1) throw new GameActionError(409, 'cannot sell the last remaining plane');
const type = PLANE_TYPES[plane.model];
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
const payout = Math.round(type.price * SELL_FRACTION * 100) / 100;
await inst.db.delete(schema.planes).where(eq(schema.planes.id, planeId));
const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing');
await inst.db.update(schema.account).set({ cash: Math.round((account.cash + payout) * 100) / 100 });
await persist(inst);
return { payout };
}
// --- Refuel (POST /api/planes/:planeId/refuel) ---
// Fill a grounded plane to maxFuel at FUEL_PRICE_PER_L per missing liter.
export async function refuelPlane(inst: GameDb, planeId: string): Promise<{ cost: number }> {
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
const plane = rows[0];
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
if (plane.status !== 'available') {
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be refueled`);
}
const cost = Math.round((plane.maxFuel - plane.fuel) * FUEL_PRICE_PER_L * 100) / 100;
const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing');
if (cost > 0 && account.cash < cost) {
throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(account.cash)}`);
}
await inst.db.update(schema.planes).set({ fuel: plane.maxFuel }).where(eq(schema.planes.id, planeId));
if (cost > 0) {
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 });
}
await persist(inst);
return { cost };
}
// --- Route helper (response convention, v0.8.0) ---
// Run an action under the sim lock — tick FIRST so validation/mutation see current
// game time — then respond with the FULL current state snapshot (same shape as
// GET /api/state), so clients never need a follow-up poll. GameActionError maps to
// its own status + {error}; anything unexpected is a 500.
export async function performAction(fn: (inst: GameDb) => Promise<unknown>): Promise<Response> {
try {
await runAction(fn);
return Response.json(await getStateSnapshot());
} catch (err) {
if (err instanceof GameActionError) {
return Response.json({ error: err.message }, { status: err.status });
}
console.error('action failed', err);
return Response.json({ error: 'action failed' }, { status: 500 });
}
}
+12
View File
@@ -10,6 +10,9 @@ export interface PlaneType {
burnPerKm: number;
// Cruise speed, km/h — used by the sim tick to advance planes along routes.
speedKmh: number;
// Market price in € (v0.8.0): buying costs `price`, selling pays out
// SELL_FRACTION × price.
price: number;
}
export const PLANE_TYPES: Record<string, PlaneType> = {
@@ -19,8 +22,17 @@ export const PLANE_TYPES: Record<string, PlaneType> = {
maxFuel: 6300,
burnPerKm: 1.75, // ~900 kg/h at 510 km/h cruise (Jet-A1 ≈ 0.8 kg/L)
speedKmh: 510,
// €200k — with the starting cash of €20k and typical hop rewards (~€1–3k),
// a second ATR takes sustained play to afford, as intended for the MVP sink.
price: 200_000,
},
};
// The plane the player starts with.
export const STARTER_MODEL = 'atr72';
// Jet-A1 fuel price in €/liter (v0.8.0): refueling cost = (maxFuel − fuel) × this.
export const FUEL_PRICE_PER_L = 0.8;
// Fraction of the catalog price paid out when selling a plane (v0.8.0).
export const SELL_FRACTION = 0.5;
+48
View File
@@ -163,4 +163,52 @@ describe('generateContract (shared seed/spawn builder)', () => {
it('rejects an airport list too small to form a route', () => {
expect(() => generateContract({ id: 'c-x', airports: AIRPORTS.slice(0, 1), rng: mulberry32(42), nowMs: NOW })).toThrow();
});
describe('forced endpoints (v0.8.0 spawn coverage + test hook)', () => {
it('originIcao forces the origin; dest stays random but distinct', () => {
const c = generateContract({ id: 'c-o', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: 'EDDF' });
expect(c.originIcao).toBe('EDDF');
expect(c.destIcao).not.toBe('EDDF');
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
expect(c.reward).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon)));
});
it('destIcao forces the destination; origin stays random but distinct', () => {
const c = generateContract({ id: 'c-d', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, destIcao: 'LEBC' });
expect(c.destIcao).toBe('LEBC');
expect(c.originIcao).not.toBe('LEBC');
});
it('both forced → the exact requested route with the distance-based reward', () => {
const c = generateContract({ id: 'c-b', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM' });
expect(c.originIcao).toBe('EDDF');
expect(c.destIcao).toBe('EHAM');
const o = AIRPORTS[0]; // EDDF
const d = AIRPORTS[1]; // EHAM
expect(c.reward).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon)));
});
it('is deterministic for a fixed RNG with forced endpoints', () => {
const a = generateContract({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, originIcao: 'EDDF' });
const b = generateContract({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW, originIcao: 'EDDF' });
expect(a).toEqual(b);
});
it('throws on an unknown forced ICAO or identical endpoints', () => {
expect(() => generateContract({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, originIcao: 'XXXX' })).toThrow(/unknown origin/);
expect(() => generateContract({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, destIcao: 'XXXX' })).toThrow(/unknown destination/);
expect(() => generateContract({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EDDF' })).toThrow(/must differ/);
});
it('omitting both endpoints keeps the pre-v0.8.0 RNG call order (byte-identical output)', () => {
// Same seed as a historical unforced call → same contract, proving forced
// endpoints consume no extra RNG draws when absent.
const a = generateContract({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
const b = generateContract({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: undefined, destIcao: undefined });
expect(a).toEqual(b);
});
});
});
+24 -3
View File
@@ -98,19 +98,40 @@ export function rewardForDistanceKm(km: number): number {
// deadline now + 6–72h game time (or now + deadlineOffsetMs when given — used by
// the e2e test hook to force a near-term expiry). The RNG call order per contract
// is fixed: origin, dest, cargoType, cargoCount, [deadline window].
// v0.8.0: originIcao/destIcao optionally FORCE an endpoint (spawn coverage rule +
// the e2e create-contract hook); a forced endpoint consumes no RNG draws, so
// callers that omit both get byte-identical behavior to before.
export function generateContract(input: {
id: string;
airports: SeedAirport[];
rng: Rng;
nowMs: number;
deadlineOffsetMs?: number;
originIcao?: string;
destIcao?: string;
}): SeedContract {
const { airports, rng, nowMs } = input;
if (airports.length < 2) throw new Error('contract generation needs at least two airports');
const oi = Math.floor(rng() * airports.length);
let di = Math.floor(rng() * airports.length);
if (di === oi) di = (di + 1) % airports.length;
let oi: number;
if (input.originIcao !== undefined) {
oi = airports.findIndex((a) => a.icao === input.originIcao);
if (oi < 0) throw new Error(`unknown origin airport ${input.originIcao}`);
} else {
oi = Math.floor(rng() * airports.length);
}
let di: number;
if (input.destIcao !== undefined) {
di = airports.findIndex((a) => a.icao === input.destIcao);
if (di < 0) throw new Error(`unknown destination airport ${input.destIcao}`);
// A RANDOM origin may have landed on the forced destination — shift it (no
// extra RNG draw), mirroring the unforced collision handling below.
if (oi === di && input.originIcao === undefined) oi = (oi + 1) % airports.length;
} else {
di = Math.floor(rng() * airports.length);
if (di === oi) di = (di + 1) % airports.length;
}
if (oi === di) throw new Error('origin and destination must differ'); // both forced identical
const origin = airports[oi];
const dest = airports[di];
+4
View File
@@ -16,6 +16,9 @@ export interface PlaneState {
locationIcao: string;
locationName: string;
contractId: string | null;
// Sim-clock departure time of the current flight (v0.8.0) — null when grounded,
// set by dispatch while flying so clients can compute progress/ETA from the clock.
departureSimTime: number | null;
lat: number;
lon: number;
}
@@ -92,6 +95,7 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
locationIcao: p.locationIcao,
locationName: nameOf(p.locationIcao),
contractId: p.contractId,
departureSimTime: p.departureSimTime,
lat: live?.lat ?? p.lat,
lon: live?.lon ?? p.lon,
};