Fixed world clock: no player time controls (ISS-117)
CI / build + unit + e2e (pull_request) Failing after 12m10s
CI / build + unit + e2e (pull_request) Failing after 12m10s
The world always runs at WORLD_TIME_SCALE = 24 (lib/worldClock.ts). A server
background tick (instrumentation.ts -> startBackgroundTick, 1 s interval)
advances the sim with no open browser; idle-pause freezes the clock exactly
while no plane is flying or in repair. Fresh worlds start year 2000
(STARTING_CLOCK_MS in seed.ts).
- Removed: POST /api/sim + OpenAPI entry, applyTimeControls/clampTimeScale,
lib/timeScale.ts, LeftPane pause button + scale slider machinery,
ISS-057 slow-on-landing/resume in flyPlane.
- Snapshot simState is now { clockMs } only; client extrapolates at the fixed
rate between polls and freezes while idle (useSimClock).
- New test-only hook POST /api/test/set-timescale behind FLIGHT_TEST_HOOKS=1
(sibling of set-health); all e2e setScale helpers call it, afterEach restores
the world rate. E2E pause tests replaced by idle-pause assertions; sim.spec.ts
activates the world via set-health + /repair for advance/expiry tests.
This commit is contained in:
@@ -21,7 +21,7 @@ import {
|
||||
repRate,
|
||||
SELL_FRACTION,
|
||||
} from './catalog';
|
||||
import { HUB_ICAO } from './seed';
|
||||
import { HUB_ICAO, STARTING_CLOCK_MS } from './seed';
|
||||
import {
|
||||
abandonPackage,
|
||||
buyPlane,
|
||||
@@ -173,7 +173,7 @@ describe('flyPlane', () => {
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('flying');
|
||||
expect(plane.destIcao).toBe('LEMD');
|
||||
expect(plane.departureSimTime).toBe(T0); // seeded clock, no tick in direct calls
|
||||
expect(plane.departureSimTime).toBe(STARTING_CLOCK_MS); // seeded game clock (ISS-117), 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);
|
||||
@@ -182,34 +182,6 @@ describe('flyPlane', () => {
|
||||
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel);
|
||||
});
|
||||
|
||||
it('resumes a manual pause and restores lastSelectedTimeScale when the last grounded plane takes off (ISS-057)', async () => {
|
||||
const inst = await freshDb('fly-resume.db');
|
||||
// A landing slowed the sim to 1×; the player's last manually selected speed was 250.
|
||||
await inst.db.update(schema.simState).set({ paused: true, timeScale: 1, lastSelectedTimeScale: 250 });
|
||||
|
||||
await flyPlane(inst, 'plane-starter', 'LEMD'); // sole grounded plane → airborne
|
||||
|
||||
const sim = (await inst.db.select().from(schema.simState))[0];
|
||||
expect(sim.paused).toBe(false); // dispatch still resumes a manual pause…
|
||||
expect(sim.timeScale).toBe(250); // …and restores the last selected speed
|
||||
});
|
||||
|
||||
it('keeps 1× when another plane is still grounded after dispatch (ISS-057)', async () => {
|
||||
const inst = await freshDb('fly-stayslow.db');
|
||||
await inst.db.update(schema.simState).set({ timeScale: 1, lastSelectedTimeScale: 250 });
|
||||
// A second grounded plane — the starter taking off leaves it on the ground.
|
||||
await inst.db.insert(schema.planes).values({
|
||||
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
||||
});
|
||||
|
||||
await flyPlane(inst, 'plane-starter', 'LEMD');
|
||||
|
||||
const sim = (await inst.db.select().from(schema.simState))[0];
|
||||
expect(sim.paused).toBe(false);
|
||||
expect(sim.timeScale).toBe(1); // another plane on ground → stays slowed
|
||||
});
|
||||
|
||||
it('rejects a flying plane with 409', async () => {
|
||||
const inst = await freshDb('fly-flying.db');
|
||||
await inst.db.update(schema.planes)
|
||||
@@ -462,14 +434,14 @@ describe('cancelQueuedLeg (v0.25.0, ISS-043)', () => {
|
||||
});
|
||||
|
||||
describe('abandonPackage', () => {
|
||||
// A loaded package created at T0 with a 10h window; the seeded sim clock is T0.
|
||||
// A loaded package created at the seeded game clock with a 10h window (ISS-117).
|
||||
const REWARD = 1_000;
|
||||
const WINDOW_MS = 10 * 3_600_000;
|
||||
|
||||
async function setupLoaded(name: string, status: 'loaded' | 'expired' = 'loaded') {
|
||||
const inst = await freshDb(name);
|
||||
// Always insert OPEN and load it through the real action…
|
||||
await insertOpenPackage(inst, 'p-ab', { reward: REWARD, createdSimTime: T0, deadlineSimTime: T0 + WINDOW_MS });
|
||||
await insertOpenPackage(inst, 'p-ab', { reward: REWARD, createdSimTime: STARTING_CLOCK_MS, deadlineSimTime: STARTING_CLOCK_MS + WINDOW_MS });
|
||||
await loadPackage(inst, 'p-ab', 'plane-starter');
|
||||
// …then flip to expired in place (what the tick would do once the deadline passes).
|
||||
if (status === 'expired') {
|
||||
@@ -494,7 +466,7 @@ describe('abandonPackage', () => {
|
||||
|
||||
it('scales to exactly 37.5% at the midpoint of the window', async () => {
|
||||
const inst = await setupLoaded('ab-mid.db');
|
||||
await inst.db.update(schema.simState).set({ clockMs: T0 + WINDOW_MS / 2 }); // runAction ticks first in prod
|
||||
await inst.db.update(schema.simState).set({ clockMs: STARTING_CLOCK_MS + WINDOW_MS / 2 }); // runAction ticks first in prod
|
||||
|
||||
const { penalty } = await abandonPackage(inst, 'p-ab');
|
||||
|
||||
@@ -503,8 +475,8 @@ describe('abandonPackage', () => {
|
||||
|
||||
it('caps at exactly 60% at and past the deadline', async () => {
|
||||
for (const [name, clock] of [
|
||||
['ab-deadline.db', T0 + WINDOW_MS],
|
||||
['ab-pastdeadline.db', T0 + WINDOW_MS * 2], // clamped — a not-yet-ticked loaded package past its deadline
|
||||
['ab-deadline.db', STARTING_CLOCK_MS + WINDOW_MS],
|
||||
['ab-pastdeadline.db', STARTING_CLOCK_MS + WINDOW_MS * 2], // clamped — a not-yet-ticked loaded package past its deadline
|
||||
] as const) {
|
||||
const inst = await setupLoaded(name);
|
||||
await inst.db.update(schema.simState).set({ clockMs: clock });
|
||||
@@ -735,7 +707,7 @@ describe('repairPlane (v0.39.0, ISS-069; gradual since v0.45.0, ISS-089)', () =>
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('repairing');
|
||||
expect(plane.health).toBe(42); // NOT restored yet — the engine's finishRepairs pass heals it gradually…
|
||||
expect(plane.repairUntilSimTime).toBe(T0 + REPAIR_DURATION_MS); // …with the gate set from the seeded clock (no tick in direct calls)
|
||||
expect(plane.repairUntilSimTime).toBe(STARTING_CLOCK_MS + REPAIR_DURATION_MS); // …with the gate set from the seeded game clock (ISS-117; no tick in direct calls)
|
||||
const account = (await inst.db.select().from(schema.account))[0];
|
||||
expect(account.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6);
|
||||
});
|
||||
|
||||
@@ -173,14 +173,6 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
|
||||
if (cost > 0) {
|
||||
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 });
|
||||
}
|
||||
// ISS-057: dispatch still resumes a MANUALLY paused sim (pause is manual-only now — landings
|
||||
// slow to 1× instead of pausing), and restores the last manually selected speed when this
|
||||
// dispatch took the LAST grounded plane airborne. Runs inside runAction(), so tick + controls
|
||||
// + dispatch are atomic under the sim lock.
|
||||
const sets: Partial<Pick<schema.SimStateRow, 'paused' | 'timeScale'>> = { paused: false };
|
||||
const remaining = await inst.db.select().from(schema.planes).where(eq(schema.planes.status, 'available'));
|
||||
if (remaining.length === 0) sets.timeScale = sim.lastSelectedTimeScale;
|
||||
await inst.db.update(schema.simState).set(sets);
|
||||
await persist(inst);
|
||||
}
|
||||
|
||||
|
||||
+12
-8
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { WORLD_TIME_SCALE } from '@/lib/worldClock';
|
||||
import { haversineKm } from '@/lib/geo';
|
||||
import { PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
|
||||
import { SPECIALS } from './specials';
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
REWARD_PER_KM,
|
||||
rewardFor,
|
||||
STARTING_CASH,
|
||||
STARTING_CLOCK_MS,
|
||||
STARTING_REPUTATION,
|
||||
type Rng,
|
||||
type SeedAirport,
|
||||
@@ -73,7 +75,7 @@ describe('buildSeed', () => {
|
||||
expect(c.originIcao).not.toBe(c.destIcao);
|
||||
expect(c.status).toBe('open');
|
||||
expect(c.planeId).toBeNull();
|
||||
expect(c.createdSimTime).toBe(NOW);
|
||||
expect(c.createdSimTime).toBe(STARTING_CLOCK_MS); // game-clock origin, not wall time (ISS-117)
|
||||
}
|
||||
expect(new Set(seed.packages.map((c) => c.id)).size).toBe(seed.packages.length);
|
||||
});
|
||||
@@ -101,22 +103,23 @@ describe('buildSeed', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('sets deadlines inside the 6–72h game-time window after now (scaled by any special status)', () => {
|
||||
it('sets deadlines inside the 6–72h game-time window after the game-clock origin (scaled by any special status)', () => {
|
||||
const seed = seedWith(42);
|
||||
for (const c of seed.packages) {
|
||||
// v0.37.0 (ISS-073): a special's deadlineScale tightens the drawn window (e.g. priority → 3–36h).
|
||||
// ISS-117: the window is anchored at STARTING_CLOCK_MS, not wall time.
|
||||
const scale = c.special ? SPECIALS[c.special]?.deadlineScale ?? 1 : 1;
|
||||
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 * scale - 1);
|
||||
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000 * scale);
|
||||
expect(c.deadlineSimTime).toBeGreaterThan(STARTING_CLOCK_MS + 6 * 3_600_000 * scale - 1);
|
||||
expect(c.deadlineSimTime).toBeLessThanOrEqual(STARTING_CLOCK_MS + 72 * 3_600_000 * scale);
|
||||
}
|
||||
});
|
||||
|
||||
it('seeds the starting account and sim state', () => {
|
||||
const seed = seedWith(42);
|
||||
expect(seed.account).toEqual({ cash: STARTING_CASH, reputation: STARTING_REPUTATION });
|
||||
// lastRealTickMs starts at the seed moment so the first tick-on-read advances ~0;
|
||||
// lastSelectedTimeScale mirrors the seeded speed (v0.30.0, ISS-057).
|
||||
expect(seed.simState).toEqual({ clockMs: NOW, timeScale: 1, paused: false, lastRealTickMs: NOW, lastSelectedTimeScale: 1 });
|
||||
// ISS-117 (v0.52.0): game clock starts at year 2000 with the fixed world rate; lastRealTickMs is
|
||||
// the REAL seed moment so the first tick-on-read advances ~0. paused/lastSelectedTimeScale are legacy columns.
|
||||
expect(seed.simState).toEqual({ clockMs: STARTING_CLOCK_MS, timeScale: WORLD_TIME_SCALE, paused: false, lastRealTickMs: NOW, lastSelectedTimeScale: WORLD_TIME_SCALE });
|
||||
});
|
||||
|
||||
it('is fully deterministic for a fixed RNG and inputs', () => {
|
||||
@@ -186,7 +189,8 @@ describe('generatePackage (shared seed/spawn builder)', () => {
|
||||
const count = PACKAGE_COUNT_MIN + Math.floor(rngA() * (PACKAGE_COUNT_MAX - PACKAGE_COUNT_MIN + 1));
|
||||
const expected: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
expected.push(JSON.stringify(generatePackage({ id: `package-${i + 1}`, airports: AIRPORTS, rng: rngA, nowMs: NOW })));
|
||||
// ISS-117: buildSeed anchors packages at the game-clock origin, not the wall-time input.
|
||||
expected.push(JSON.stringify(generatePackage({ id: `package-${i + 1}`, airports: AIRPORTS, rng: rngA, nowMs: STARTING_CLOCK_MS })));
|
||||
}
|
||||
const seed = buildSeed({ airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
|
||||
expect(seed.packages.map((c) => JSON.stringify(c))).toEqual(expected);
|
||||
|
||||
+13
-7
@@ -1,4 +1,5 @@
|
||||
import { haversineKm } from '@/lib/geo';
|
||||
import { WORLD_TIME_SCALE } from '@/lib/worldClock';
|
||||
import { PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
|
||||
import { pickSpecial, SPECIALS } from './specials';
|
||||
|
||||
@@ -75,6 +76,10 @@ export interface SeedData {
|
||||
export const STARTING_CASH = 20_000;
|
||||
export const STARTING_REPUTATION = 10;
|
||||
|
||||
// ISS-117 (v0.52.0): fresh worlds start at the top of year 2000 — a fixed game-time origin so
|
||||
// every new save begins from the same moment. Existing saves keep their own clock (no migration).
|
||||
export const STARTING_CLOCK_MS = Date.UTC(2000, 0, 1);
|
||||
|
||||
// The starter plane grounds at a large central-European hub. If the ICAO is
|
||||
// ever missing from the dataset we fall back to the first airport rather than
|
||||
// failing first run. (EDDF = Frankfurt; EDDM is Munich — don't mix them up.)
|
||||
@@ -291,20 +296,21 @@ export function buildSeed(input: {
|
||||
|
||||
const count = PACKAGE_COUNT_MIN + Math.floor(rng() * (PACKAGE_COUNT_MAX - PACKAGE_COUNT_MIN + 1));
|
||||
// Stable index-based ids for the seed set; runtime-generated entities must use
|
||||
// random UUIDs to avoid colliding with these.
|
||||
// random UUIDs to avoid colliding with these. Deadlines are drawn relative to the GAME clock
|
||||
// origin (ISS-117), not the real wall time, so they stay meaningful at the fixed world rate.
|
||||
const packages: SeedPackage[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
packages.push(generatePackage({ id: `package-${i + 1}`, airports, rng, nowMs }));
|
||||
packages.push(generatePackage({ id: `package-${i + 1}`, airports, rng, nowMs: STARTING_CLOCK_MS }));
|
||||
}
|
||||
|
||||
return {
|
||||
plane,
|
||||
packages,
|
||||
account: { cash: STARTING_CASH, reputation: STARTING_REPUTATION },
|
||||
// lastRealTickMs starts at the seed moment so the first tick-on-read advances
|
||||
// by ~0 instead of treating "time since install" as elapsed play time.
|
||||
// lastSelectedTimeScale (v0.30.0, ISS-057) mirrors the seeded speed — nothing has been
|
||||
// selected manually yet, so resume would restore 1× anyway.
|
||||
simState: { clockMs: nowMs, timeScale: 1, paused: false, lastRealTickMs: nowMs, lastSelectedTimeScale: 1 },
|
||||
// ISS-117 (v0.52.0): the game clock starts at year 2000 with the fixed world rate; lastRealTickMs
|
||||
// is the REAL seed moment so the first tick advances by ~0 instead of treating "time since
|
||||
// install" as elapsed play time. paused/lastSelectedTimeScale are legacy columns kept for schema
|
||||
// stability — no game code reads them anymore.
|
||||
simState: { clockMs: STARTING_CLOCK_MS, timeScale: WORLD_TIME_SCALE, paused: false, lastRealTickMs: nowMs, lastSelectedTimeScale: WORLD_TIME_SCALE },
|
||||
};
|
||||
}
|
||||
|
||||
+4
-2
@@ -73,7 +73,9 @@ export interface PackageState {
|
||||
|
||||
export interface StateSnapshot {
|
||||
account: { cash: number; reputation: number };
|
||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
||||
// ISS-117 (v0.52.0): the world rate is a fixed constant (WORLD_TIME_SCALE), not per-save state —
|
||||
// only the clock itself is reported here.
|
||||
simState: { clockMs: number };
|
||||
planes: PlaneState[];
|
||||
packages: PackageState[];
|
||||
// Market reference data (v0.10.0) — static per build, sent with every snapshot so
|
||||
@@ -135,7 +137,7 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
|
||||
|
||||
return {
|
||||
account: { cash: accountRow.cash, reputation: accountRow.reputation },
|
||||
simState: { clockMs: simRow.clockMs, timeScale: simRow.timeScale, paused: simRow.paused },
|
||||
simState: { clockMs: simRow.clockMs },
|
||||
catalog: catalogEntries(),
|
||||
fuelPricePerL: FUEL_PRICE_PER_L,
|
||||
sellFraction: SELL_FRACTION,
|
||||
|
||||
Reference in New Issue
Block a user