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:
@@ -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