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:
@@ -1,5 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
## v0.8.0 — 2026-09-17 13:54
|
||||
- **Player-actions API** (PLAN.md "Core loop" increment, server-side only — NO frontend/UI changes): the core loop now works end-to-end through five new endpoints in `lib/game/actions.ts` + `app/api/`. Every action endpoint follows one convention: tick the sim clock FIRST (so validation/mutation see current game time), validate, mutate, persist atomically under the same promise lock as ticks (`engine.runAction()` — no request can interleave between tick and mutation), then return the **FULL state snapshot** (same shape as GET /api/state) with 200 so clients never need a follow-up poll; errors are proper 4xx `{error}` bodies
|
||||
- **Accept**: `POST /api/contracts/:id/accept` — open → accepted only. 404 unknown id, 409 if the contract is not 'open' (accepted/completed/expired). Single-player, so no ownership tracking beyond the status flip; dispatch binds the plane
|
||||
- **Dispatch**: `POST /api/planes/:planeId/dispatch` body `{contractId}` — validates in order: contract exists & status 'accepted' (409 otherwise), plane exists & 'available', `plane.locationIcao === contract.originIcao` (409 "not at origin"), fuel sufficiency `burnPerKm × haversineKm(origin,dest) ≤ plane.fuel` (409 "insufficient fuel"); unknown ids → 404. On success sets the plane 'flying' with `departureSimTime = current clockMs` and lat/lon pinned to the origin — the v0.7.0 finalize path then flies it analytically and pays out on arrival, unchanged
|
||||
- **Market**: `POST /api/market/buy-plane` body `{model}` (400 unknown catalog model; 409 insufficient funds) deducts the full price and delivers a full-tank 'available' plane at home base EDDF; `POST /api/market/sell-plane` body `{planeId}` (409 if flying, 409 for the last remaining plane — the company always keeps ≥1 aircraft) pays out 50% of catalog price and deletes the row. Catalog gains `price: 200_000` on atr72 (with €20k starting cash and ~€1–3k hop rewards, a second ATR takes sustained play — the intended MVP money sink), plus constants `FUEL_PRICE_PER_L = 0.8` €/L and `SELL_FRACTION = 0.5`
|
||||
- **Refuel**: `POST /api/planes/:planeId/refuel` (no body) — grounded planes only (409 if flying); cost `(maxFuel − fuel) × FUEL_PRICE_PER_L`, cash-checked (409), fills to max; a full tank is a free no-op
|
||||
- **Spawn coverage rule** (`engine.spawnCoverageContracts`, in-tick after top-up + finalization): in addition to topping the market up to 10 random open contracts, every airport that currently has a grounded 'available' plane must have ≥1 OPEN contract originating there — one generated per uncovered airport via `generateContract` with origin forced. Guarantees ongoing work wherever the fleet sits (including onboarding at EDDF), and because it runs after finalization a plane that just landed gets work spawned at its new location in the same tick; flying planes don't count
|
||||
- **`generateContract` gains optional `originIcao`/`destIcao`** to force endpoints (used by the coverage rule + test hook); forced endpoints consume no RNG draws, so existing callers are byte-identical (a random origin landing on a forced destination is shifted, mirroring the unforced collision handling)
|
||||
- **Snapshot gains `planes[].departureSimTime`** (null when grounded; sim-clock ms set by dispatch while flying) so clients can compute progress/ETA from the clock — documented in OpenAPI PlaneState
|
||||
- **Test hook generalized**: new `POST /api/test/create-contract` body `{originIcao?, destIcao?, deadlineOffsetMs?}` creates an open contract on a known route (400 unknown ICAO/identical endpoints; default 6–72h window), sibling of `/api/test/expire-contract` under the same `FLIGHT_TEST_HOOKS=1` gate — lets e2e exercise the full loop on a short, deterministic route
|
||||
- **OpenAPI**: all five action endpoints documented with request schemas (DispatchRequest/BuyPlaneRequest/SellPlaneRequest), path parameters, per-endpoint 4xx error shapes and the StateSnapshot success shape; `lib/openapi.test.ts` + `e2e/api.spec.ts` endpoint lists updated four → nine
|
||||
- Tests: unit — new `lib/game/actions.test.ts` (full validation matrix: accept non-open/unknown; dispatch wrong-status ×3, unknown ids, flying plane, not-at-origin, insufficient fuel, success state incl. frozen departure fuel; buy unknown-model/poor/success with home-base delivery; sell flying/last-plane/unknown/success payout+delete; refuel cost math/full-tank no-op/flying/poor/unknown), `lib/sim/engine.test.ts` gains spawn-coverage tests (uncovered grounded airport ⇒ forced-origin contract, already-covered no-op, multi-airport dedupe, flying planes ignored, full tickOnce pipeline) and `lib/game/seed.test.ts` the forced-endpoint matrix; new colocated route tests for all five endpoints (HTTP status codes + snapshot response convention against temp DBs); e2e — new `e2e/actions.spec.ts`: THE full loop on the live origin (self-healing starter plane, hook-created EDDF→closest-airport contract, accept → dispatch with departure set and position moving across polls at scale 60 → arrival paying the EXACT reward + rep gain with analytic remaining fuel → refuel cost deduction → fly home to restore onboarding state) plus the dispatch-without-accept 409 / unknown-id 404 path
|
||||
- Verified: unit suite 86 → 143 green; `tsc --noEmit` clean; e2e 16 → 18 green against the rebuilt compose instance (full loop measured at scale 60); live curl of dispatch + arrival shows the exact cash/rep delta
|
||||
|
||||
## v0.7.0 — 2026-09-17 12:27
|
||||
- **Server-authoritative sim engine** (PLAN.md "Simulation engine" increment 2) in new `lib/sim/` — no frontend/UI changes; dispatch of planes lands next increment, but all the clock/market/finalization machinery it builds on is live and tested now
|
||||
- **Tick-on-read game clock** (`lib/sim/engine.ts`, NO background timer): every read/action endpoint calls `tickNow()` first (GET /api/state, POST /api/sim — and any future action endpoint); the tick advances `clockMs` by `(real ms since last tick) × timeScale`. New `sim_state.last_real_tick_ms` column stores the real-time anchor (seed sets it to the seed moment). **Clamp: `MAX_TICK_ELAPSED_MS = 2000`** — a single tick counts at most 2 s of real absence, chosen above a normal ~1 s poll interval (active play advances ~real time × scale) but small enough that one stale read after an absence moves game time by ≤ 2 s × scale (≤ 2 min at max scale 60), so the sim only meaningfully progresses while actively polled. While paused the clock is frozen but `lastRealTickMs` keeps tracking real time, so unpausing resumes with no catch-up jump. Ticks are serialized through a module-level promise lock (concurrent Next.js requests can't double-advance) and each tick persists via the existing sql.js atomic export
|
||||
|
||||
Reference in New Issue
Block a user