feat: v0.7.0 — server-authoritative sim engine: tick-on-read clock (time-scale + pause via POST /api/sim), contract expiry + market spawn, analytic plane movement/finalization
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { haversineKm } from '@/lib/geo';
|
||||
import { arrivalTimeFor, fuelAt, positionAt, progressAt, travelDurationMs } from './movement';
|
||||
|
||||
// Synthetic in-flight fixtures — no DB needed: everything is a pure function of
|
||||
// the sim clock. ATR 72-600 numbers from lib/game/catalog.ts.
|
||||
const DEP = 1_750_000_000_000; // departure sim time (ms)
|
||||
const SPEED = 510; // km/h
|
||||
const BURN = 1.75; // L/km
|
||||
|
||||
// Frankfurt → Madrid, real coordinates from the CSV dataset.
|
||||
const FROM = { lat: 50.0333, lon: 8.5706 };
|
||||
const TO = { lat: 40.47, lon: -3.56 };
|
||||
const KM = haversineKm(FROM.lat, FROM.lon, TO.lat, TO.lon); // ≈ 2190 km
|
||||
|
||||
describe('travelDurationMs / arrivalTimeFor', () => {
|
||||
it('duration is distance/speed converted to ms', () => {
|
||||
expect(travelDurationMs(510, SPEED)).toBe(3_600_000); // one hour at cruise
|
||||
expect(travelDurationMs(KM, SPEED)).toBeCloseTo((KM / SPEED) * 3_600_000, 9);
|
||||
});
|
||||
|
||||
it('rejects a non-positive speed', () => {
|
||||
expect(() => travelDurationMs(100, 0)).toThrow();
|
||||
});
|
||||
|
||||
it('arrival is departure + duration', () => {
|
||||
const arrival = arrivalTimeFor(DEP, KM, SPEED);
|
||||
expect(arrival).toBeCloseTo(DEP + (KM / SPEED) * 3_600_000, 9);
|
||||
expect(arrival).toBeGreaterThan(DEP);
|
||||
});
|
||||
});
|
||||
|
||||
describe('progressAt', () => {
|
||||
const duration = travelDurationMs(KM, SPEED);
|
||||
|
||||
it('is 0 before departure and at departure', () => {
|
||||
expect(progressAt(DEP, KM, SPEED, DEP - 1)).toBe(0);
|
||||
expect(progressAt(DEP, KM, SPEED, DEP)).toBe(0);
|
||||
});
|
||||
|
||||
it('is linear in the middle of the flight', () => {
|
||||
// Precision 9: clock values sit at ~1.75e12 ms, so (clock - DEP) loses a few
|
||||
// ulps to cancellation — 1e-9 progress is still sub-millisecond on this leg.
|
||||
expect(progressAt(DEP, KM, SPEED, DEP + duration / 2)).toBeCloseTo(0.5, 9);
|
||||
expect(progressAt(DEP, KM, SPEED, DEP + duration * 0.25)).toBeCloseTo(0.25, 9);
|
||||
});
|
||||
|
||||
it('is exactly 1 at arrival and stays 1 after', () => {
|
||||
const arrival = DEP + duration;
|
||||
expect(progressAt(DEP, KM, SPEED, arrival)).toBe(1);
|
||||
expect(progressAt(DEP, KM, SPEED, arrival + 10_000)).toBe(1);
|
||||
});
|
||||
|
||||
it('a zero-distance leg is complete immediately', () => {
|
||||
expect(progressAt(DEP, 0, SPEED, DEP - 5)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('positionAt (great-circle slerp)', () => {
|
||||
it('returns the exact endpoints at progress 0 and 1', () => {
|
||||
expect(positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, 0)).toEqual({ lat: FROM.lat, lon: FROM.lon });
|
||||
expect(positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, 1)).toEqual({ lat: TO.lat, lon: TO.lon });
|
||||
});
|
||||
|
||||
it('clamps out-of-range progress to the endpoints', () => {
|
||||
expect(positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, -0.5)).toEqual({ lat: FROM.lat, lon: FROM.lon });
|
||||
expect(positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, 1.5)).toEqual({ lat: TO.lat, lon: TO.lon });
|
||||
});
|
||||
|
||||
it('stays on the great circle: arc lengths add up to the total distance', () => {
|
||||
for (const t of [0.1, 0.37, 0.5, 0.9]) {
|
||||
const p = positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, t);
|
||||
const d1 = haversineKm(FROM.lat, FROM.lon, p.lat, p.lon);
|
||||
const d2 = haversineKm(p.lat, p.lon, TO.lat, TO.lon);
|
||||
expect(d1 + d2).toBeCloseTo(KM, 6); // slerp keeps the point on the arc
|
||||
}
|
||||
});
|
||||
|
||||
it('the midpoint is equidistant from both ends', () => {
|
||||
const m = positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, 0.5);
|
||||
expect(haversineKm(FROM.lat, FROM.lon, m.lat, m.lon)).toBeCloseTo(KM / 2, 6);
|
||||
expect(haversineKm(m.lat, m.lon, TO.lat, TO.lon)).toBeCloseTo(KM / 2, 6);
|
||||
});
|
||||
|
||||
it('is exact along the equator (great circle = constant latitude)', () => {
|
||||
for (const t of [0.25, 0.5, 0.75]) {
|
||||
const p = positionAt(0, -10, 0, 10, t);
|
||||
expect(p.lat).toBeCloseTo(0, 9);
|
||||
expect(p.lon).toBeCloseTo(-10 + 20 * t, 9);
|
||||
}
|
||||
});
|
||||
|
||||
it('handles the same-point route without dividing by zero', () => {
|
||||
const p = positionAt(48.85, 2.35, 48.85, 2.35, 0.5);
|
||||
expect(p).toEqual({ lat: 48.85, lon: 2.35 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('fuelAt', () => {
|
||||
it('burns fuel proportionally to distance flown', () => {
|
||||
const start = 6_300; // full ATR tank
|
||||
expect(fuelAt(start, BURN, KM, 0)).toBe(start);
|
||||
expect(fuelAt(start, BURN, KM, 0.5)).toBeCloseTo(start - BURN * KM / 2, 9);
|
||||
expect(fuelAt(start, BURN, KM, 1)).toBeCloseTo(start - BURN * KM, 9);
|
||||
});
|
||||
|
||||
it('clamps at zero instead of going negative', () => {
|
||||
expect(fuelAt(100, BURN, 1_000, 1)).toBe(0); // needs 1750 L, has 100
|
||||
expect(fuelAt(100, BURN, 1_000, 0.01)).toBeCloseTo(100 - 17.5, 9);
|
||||
});
|
||||
|
||||
it('composes with progressAt for a just-before-arrival snapshot', () => {
|
||||
const duration = travelDurationMs(KM, SPEED);
|
||||
const clock = DEP + duration - 1; // one ms before arrival
|
||||
const p = positionAt(FROM.lat, FROM.lon, TO.lat, TO.lon, progressAt(DEP, KM, SPEED, clock));
|
||||
expect(haversineKm(p.lat, p.lon, TO.lat, TO.lon)).toBeGreaterThan(0); // not yet at the gate
|
||||
expect(fuelAt(6_300, BURN, KM, progressAt(DEP, KM, SPEED, clock))).toBeCloseTo(
|
||||
6_300 - BURN * KM * (1 - 1 / duration),
|
||||
6,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user