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:
2026-09-17 12:38:52 +00:00
parent 7fdc43bc7e
commit 96835e935d
22 changed files with 1393 additions and 55 deletions
+57 -1
View File
@@ -6,6 +6,7 @@ import {
buildSeed,
CONTRACT_COUNT_MAX,
CONTRACT_COUNT_MIN,
generateContract,
HUB_ICAO,
mulberry32,
REWARD_PER_KM,
@@ -88,7 +89,8 @@ describe('buildSeed', () => {
it('seeds the starting account and sim state', () => {
const seed = seedWith(42);
expect(seed.account).toEqual({ cash: STARTING_CASH, reputation: STARTING_REPUTATION });
expect(seed.simState).toEqual({ clockMs: NOW, timeScale: 1, paused: false });
// lastRealTickMs starts at the seed moment so the first tick-on-read advances ~0.
expect(seed.simState).toEqual({ clockMs: NOW, timeScale: 1, paused: false, lastRealTickMs: NOW });
});
it('is fully deterministic for a fixed RNG and inputs', () => {
@@ -108,3 +110,57 @@ describe('buildSeed', () => {
expect(rewardForDistanceKm(1000)).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * 1000));
});
});
describe('generateContract (shared seed/spawn builder)', () => {
it('builds an open contract between distinct fixture airports with the reward formula', () => {
const c = generateContract({ id: 'c-x', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
expect(c.id).toBe('c-x');
expect(c.status).toBe('open');
expect(c.planeId).toBeNull();
expect(c.createdSimTime).toBe(NOW);
const icaoSet = new Set(AIRPORTS.map((a) => a.icao));
expect(icaoSet.has(c.originIcao)).toBe(true);
expect(icaoSet.has(c.destIcao)).toBe(true);
expect(c.originIcao).not.toBe(c.destIcao);
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)));
// Same 6–72h game-time deadline window as the seed.
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 - 1);
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000);
});
it('is deterministic for a fixed RNG and honors the id/nowMs inputs', () => {
const a = generateContract({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW });
const b = generateContract({ id: 'c-1', airports: AIRPORTS, rng: mulberry32(7), nowMs: NOW });
expect(a).toEqual(b);
const c = generateContract({ id: 'c-2', airports: AIRPORTS, rng: mulberry32(8), nowMs: NOW + 1000 });
expect(c.id).toBe('c-2');
expect(c.createdSimTime).toBe(NOW + 1000);
expect(c.deadlineSimTime).not.toBe(a.deadlineSimTime); // different RNG/now → different contract
});
it('deadlineOffsetMs pins the deadline to now + offset (e2e expiry hook)', () => {
const c = generateContract({ id: 'c-hook', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, deadlineOffsetMs: 10_000 });
expect(c.deadlineSimTime).toBe(NOW + 10_000);
});
it('buildSeed contracts are exactly the shared builder output (spawn matches seed)', () => {
const rngA = mulberry32(42);
const count = CONTRACT_COUNT_MIN + Math.floor(rngA() * (CONTRACT_COUNT_MAX - CONTRACT_COUNT_MIN + 1));
const expected: string[] = [];
for (let i = 0; i < count; i++) {
expected.push(JSON.stringify(generateContract({ id: `contract-${i + 1}`, airports: AIRPORTS, rng: rngA, nowMs: NOW })));
}
const seed = buildSeed({ airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
expect(seed.contracts.map((c) => JSON.stringify(c))).toEqual(expected);
});
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();
});
});