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:
@@ -15,6 +15,7 @@ import {
|
||||
finalizeArrivals,
|
||||
MAX_TICK_ELAPSED_MS,
|
||||
reputationGainFor,
|
||||
spawnCoverageContracts,
|
||||
spawnOpenContracts,
|
||||
tickOnce,
|
||||
} from './engine';
|
||||
@@ -257,6 +258,89 @@ describe('spawnOpenContracts', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('spawnCoverageContracts (v0.8.0 spawn coverage rule)', () => {
|
||||
// The seeded starter plane is grounded at EDDF with no seeded contract 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 () => {
|
||||
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));
|
||||
}
|
||||
|
||||
const n = await spawnCoverageContracts(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');
|
||||
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 () => {
|
||||
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));
|
||||
}
|
||||
// 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,
|
||||
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
|
||||
});
|
||||
|
||||
it('spawns one contract 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));
|
||||
}
|
||||
// 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 },
|
||||
]);
|
||||
|
||||
const n = await spawnCoverageContracts(inst, T0 + 5_000, mulberry32(7));
|
||||
expect(n).toBe(2); // EDDF (×2 planes → one contract) and LEMD
|
||||
|
||||
const origins = (await inst.db.select().from(schema.contracts))
|
||||
.filter((c) => c.status === 'open')
|
||||
.map((c) => c.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));
|
||||
}
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ status: 'flying', departureSimTime: T0, contractId: 'c-gone' })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
expect(await spawnCoverageContracts(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 () => {
|
||||
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',
|
||||
);
|
||||
expect(fromEdff.length).toBeGreaterThanOrEqual(1); // coverage held through the full tick pipeline
|
||||
});
|
||||
});
|
||||
|
||||
// Local helper mirroring lib/sim/movement.ts (kept private there on purpose — the
|
||||
// engine composes arrivalTimeFor; tests just need the duration for fixtures).
|
||||
function travelMs(km: number, speedKmh: number): number {
|
||||
|
||||
@@ -75,6 +75,9 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
|
||||
await expireContracts(inst, clockMs);
|
||||
await finalizeArrivals(inst, clockMs);
|
||||
await spawnOpenContracts(inst, clockMs);
|
||||
// AFTER finalization on purpose: a plane that just landed gets work spawned
|
||||
// at its new location within the same tick.
|
||||
await spawnCoverageContracts(inst, clockMs);
|
||||
}
|
||||
|
||||
// Persist lastRealTickMs even when nothing advanced — it is the tick anchor.
|
||||
@@ -180,12 +183,74 @@ export async function spawnOpenContracts(
|
||||
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
|
||||
// at its new location within the same tick. Returns the number created.
|
||||
export async function spawnCoverageContracts(
|
||||
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')),
|
||||
]);
|
||||
|
||||
// Distinct airports with at least one grounded plane (flying planes don't count —
|
||||
// they can't take new work until they land).
|
||||
const groundedIcas = [
|
||||
...new Set(planeRows.filter((p) => p.status === 'available').map((p) => p.locationIcao)),
|
||||
];
|
||||
if (groundedIcas.length === 0) return 0;
|
||||
|
||||
const covered = new Set(openRows.map((c) => c.originIcao));
|
||||
const missing = groundedIcas.filter((icao) => !covered.has(icao));
|
||||
if (missing.length === 0) return 0;
|
||||
|
||||
const fc = await loadAirportsGeoJson();
|
||||
const airports: SeedAirport[] = fc.features.map((f) => ({
|
||||
icao: f.properties.icao,
|
||||
name: f.properties.name,
|
||||
lat: f.properties.lat,
|
||||
lon: f.properties.lon,
|
||||
}));
|
||||
const rand = rng ?? mulberry32(randomInt(0, 2 ** 31));
|
||||
|
||||
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 }),
|
||||
);
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// --- Public API (singleton DB + lock) — call from every read/action endpoint ---
|
||||
|
||||
export function tickNow(): Promise<schema.SimStateRow> {
|
||||
return withLock(async () => tickOnce(await getGameDb()));
|
||||
}
|
||||
|
||||
// Run a game action atomically with respect to ticks (v0.8.0): the clock is
|
||||
// advanced FIRST so validation and mutation see current game time, then fn runs —
|
||||
// all under the same promise lock as tickNow(), so no concurrent request can
|
||||
// interleave between the tick and the mutation. Action functions in
|
||||
// lib/game/actions.ts take an explicit GameDb so unit tests can inject their own.
|
||||
export function runAction<T>(fn: (inst: GameDb) => Promise<T>): Promise<T> {
|
||||
return withLock(async () => {
|
||||
const inst = await getGameDb();
|
||||
await tickOnce(inst);
|
||||
return fn(inst);
|
||||
});
|
||||
}
|
||||
|
||||
// Tick first so the returned state reflects current time, then apply the new
|
||||
// controls. timeScale is clamped to [1, 60]; validation of raw input happens in
|
||||
// the route handler (400 on non-number / non-boolean).
|
||||
|
||||
Reference in New Issue
Block a user