diff --git a/CHANGELOG.md b/CHANGELOG.md index 3907836..07a66bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## v0.39.0 — 2026-09-21 12:02 +- **Maintenance (ISS-069).** Planes wear out and can be repaired: distance-based health, a broken state that burns more fuel, and a repair action that grounds the plane for 3 sim days. New balance constants in `lib/game/catalog.ts` (all ponytail-tunable): `WEAR_PER_KM = 0.005`, `BROKEN_BURN_MULTIPLIER = 1.5`, `REPAIR_COST = 1_500` (flat), `REPAIR_DURATION_MS = 3 × 86_400_000`, plus the pure helper `conditionBurnMultiplier(health)` — exactly 1 above zero health, ×1.5 at/below it +- **Schema.** Two new `planes` columns in all four places: Drizzle schema (`lib/db/schema.ts`), raw DDL + guarded `ALTER TABLE … ADD COLUMN` migrations in `migrateColumns()` (pre-existing rows backfill to full health / NULL gate, mirroring the weight_kg/special precedent), and the seed insert path — starter plane in `buildSeed` (`lib/game/seed.ts`) and every `buyPlane` delivery start at `health: 100`. New plane status `'repairing'` added to the three pinned unions (schema enum, `PlaneState`, OpenAPI) plus `mapData.ts`'s feature props +- **Wear.** At landing only — `deliverPackages` (`lib/sim/engine.ts`) drops health by `WEAR_PER_KM × distanceKm` per landed leg (clamped at 0), in both the grounded and auto-chain write branches. A broken plane is one with `health ≤ 0`: it still flies, but burns ×1.5 more fuel — applied as a third factor alongside wind at ALL FIVE burn sites that hold the plane row: `flyPlane` dispatch feasibility + `refuelPlane` forFlight requirement (`lib/game/actions.ts`), engine landed-fuel + auto-chain next-leg check (`lib/sim/engine.ts`), and the live in-flight gauge in `livePlane()` (`lib/game/state.ts`). The engine's landed fuel uses the PRE-landing health (frozen at departure, like fuel) so it stays bit-consistent with the UI gauge; the chained leg's feasibility uses the post-wear value +- **Repair.** New action `repairPlane` (`lib/game/actions.ts`) + POST `/api/planes/{planeId}/repair` (no body): 404 unknown plane, 409 not grounded ("only grounded planes can be repaired"), free no-op at full health (mirrors refuel), else charges the flat `REPAIR_COST`, restores health to 100 and grounds the plane in `'repairing'` with a gate at `clockMs + REPAIR_DURATION_MS`. The engine's new `finishRepairs` pass (same shape as `expirePackages`) runs inside every advancing tick — even when nothing landed — flipping gated planes back to `'available'` and clearing the gate. While repairing, the existing `status !== 'available'` guards in load/fly/refuel/sell/abandon block everything automatically +- **Snapshot & OpenAPI.** `PlaneState` gains `health: number` + `repairUntilSimTime: number | null`; the repair endpoint is documented (200 → snapshot, 404/409). Pins updated in lockstep: `lib/openapi.test.ts` (fourteen endpoints, nine action-endpoint loop with errorCodes `['404','409']`, PlaneState key list) and `e2e/api.spec.ts` +- **UI.** The fleet rows (`components/LeftPane.tsx`) show an amber health bar (new `.health-bar`, modeled on `.fuel-bar`) + a `{n}%` readout next to the fuel text; grounded planes get a red "broken" pill at zero health and an amber "in repair" pill while the gate runs (`.status-broken` / `.status-repairing`). A Repair button appears for any grounded plane below full health — no cost preview, flat cost is server-side only +- **Test hook.** POST `/api/test/set-health` (`FLIGHT_TEST_HOOKS=1` only): `{ planeId, health?, repairUntilMs? }` forces a plane's health (finite in [0,100]) and/or sets its repair gate to `clock + repairUntilMs` — lets e2e force the broken state and shorten an active 3-day gate without flying thousands of km +- **Tests.** New unit tests: wear accumulation per landed leg + clamping at 0 across multiple legs; broken-state ×1.5 landed fuel vs a healthy control on the identical leg; `conditionBurnMultiplier` pins; `flyPlane` feasibility using the adjusted requirement (same tank flyable at full health, 409 when broken); `repairPlane` (exact cost/status/gate/health, free no-op, 409s for flying/repairing/insufficient funds, load/fly/refuel/sell all blocked while repairing) and `finishRepairs` (gate boundary + flip-back through a full `tickOnce` with nothing landed). E2E: new `e2e/maintenance.spec.ts` — the visible flow per the issue (force 42 → UI shows it; force 0 → broken pill + short full-tank leg lands with exactly the ×1.5-adjusted fuel from the real lib helpers; click Repair → exact cash drop + "in repair" pill; shorten the gate via the hook → back to service at health 100), restoring a clean starter on exit +- **E2E drift mitigation.** Wear accumulates across the whole CI run (one shared DB, workers:1) — the self-heal helpers in `e2e/right-pane.spec.ts` (`flyHomeViaApi`), `e2e/two-planes.spec.ts` (`resetFleet`) and `e2e/actions.spec.ts` (`flyHomeViaApi`) now restore full health + clear any active repair gate first, so the farthest-flyable pickers and the FAR round-trip feasibility pin never see a worn or broken starter + ## v0.38.0 — 2026-09-21 10:47 - **Reputation economy (ISS-072).** Reputation is no longer a dead stat — it drives the economy through three simple tunable formulas, all built on one new pure helper `repRate(reputation, perPoint, cap)` in `lib/game/catalog.ts` (linear above the starting value of 10, capped; exactly 0 at/below start so default-rep behavior is byte-identical): - **Cheaper plane purchases** — `buyPlane` (`lib/game/actions.ts`) charges `price × (1 − repRate(rep, PLANE_DISCOUNT_PER_REP = 0.01, MAX_PLANE_DISCOUNT = 0.4))`, rounded to 2dp; the effective cost drives both the funds check and the deduction (up to 40% off at rep ≥ 50) diff --git a/app/api/planes/[planeId]/repair/route.ts b/app/api/planes/[planeId]/repair/route.ts new file mode 100644 index 0000000..0e55838 --- /dev/null +++ b/app/api/planes/[planeId]/repair/route.ts @@ -0,0 +1,12 @@ +import { performAction, repairPlane } from '@/lib/game/actions'; + +// POST /api/planes/:planeId/repair (v0.39.0, ISS-069) — no body. Restores a worn-out plane to +// full health for the flat REPAIR_COST: it is grounded in "repairing" status until 3 sim days +// pass, when the engine's tick flips it back to "available". A full-health repair is a free +// no-op (cost 0). 404 unknown id; 409 plane not grounded or insufficient funds. On success +// returns the full state snapshot. + +export async function POST(_req: Request, ctx: { params: Promise<{ planeId: string }> }) { + const { planeId } = await ctx.params; + return performAction((inst) => repairPlane(inst, planeId)); +} diff --git a/app/api/test/set-health/route.ts b/app/api/test/set-health/route.ts new file mode 100644 index 0000000..cac2b4e --- /dev/null +++ b/app/api/test/set-health/route.ts @@ -0,0 +1,82 @@ +import { eq } from 'drizzle-orm'; +import { getGameDb, persist } from '@/lib/db/client'; +import * as schema from '@/lib/db/schema'; +import { getStateSnapshot } from '@/lib/game/state'; +import { tickNow } from '@/lib/sim/engine'; + +// TEST-ONLY hook (v0.39.0, ISS-069) — lets the e2e suite force a plane's health and/or shorten an +// active repair gate without flying thousands of km or waiting 3 sim days. Disabled unless +// FLIGHT_TEST_HOOKS=1 (set in docker-compose.yml for the e2e instance only); every other +// deployment 404s here. Sibling of /api/test/add-cash — same gate, same conventions. +// +// POST /api/test/set-health +// body { planeId: string, health?: number, repairUntilMs?: number } +// - planeId is required and must name an existing plane (404 otherwise); +// - at least one of health / repairUntilMs must be present; +// - health must be a finite number in [0, 100]; +// - repairUntilMs sets repair_until_sim_time = clock + repairUntilMs (the expire-package +// near-term-gate precedent) so the next advancing tick flips a shortened repair back. +// Returns the full state snapshot after ticking. + +export async function POST(req: Request) { + if (process.env.FLIGHT_TEST_HOOKS !== '1') { + return Response.json({ error: 'test hooks disabled' }, { status: 404 }); + } + + let body: unknown = {}; + try { + const text = await req.text(); + body = text ? JSON.parse(text) : {}; + } catch { + return Response.json({ error: 'invalid JSON body' }, { status: 400 }); + } + if (typeof body !== 'object' || body === null || Array.isArray(body)) { + return Response.json({ error: 'body must be a JSON object' }, { status: 400 }); + } + const input = body as Record; + for (const key of Object.keys(input)) { + if (key !== 'planeId' && key !== 'health' && key !== 'repairUntilMs') { + return Response.json({ error: `unknown field "${key}"` }, { status: 400 }); + } + } + const planeId = input.planeId; + if (typeof planeId !== 'string' || planeId.length === 0) { + return Response.json({ error: 'planeId is required and must be a non-empty string' }, { status: 400 }); + } + let health: number | undefined; + if (input.health !== undefined) { + if (typeof input.health !== 'number' || !Number.isFinite(input.health) || input.health < 0 || input.health > 100) { + return Response.json({ error: 'health must be a finite number in [0, 100]' }, { status: 400 }); + } + health = input.health; + } + let repairUntilMs: number | undefined; + if (input.repairUntilMs !== undefined) { + if (typeof input.repairUntilMs !== 'number' || !Number.isFinite(input.repairUntilMs)) { + return Response.json({ error: 'repairUntilMs must be a finite number' }, { status: 400 }); + } + repairUntilMs = input.repairUntilMs; + } + if (health === undefined && repairUntilMs === undefined) { + return Response.json({ error: 'at least one of health or repairUntilMs is required' }, { status: 400 }); + } + + try { + const inst = await getGameDb(); + const sim = await tickNow(); // clock is current before we set the gate (same as siblings) + const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)); + if (!rows[0]) { + return Response.json({ error: `plane ${planeId} not found` }, { status: 404 }); + } + + const sets: Partial> = {}; + if (health !== undefined) sets.health = health; + if (repairUntilMs !== undefined) sets.repairUntilSimTime = Math.round(sim.clockMs + repairUntilMs); + await inst.db.update(schema.planes).set(sets).where(eq(schema.planes.id, planeId)); + await persist(inst); + return Response.json(await getStateSnapshot()); + } catch (err) { + console.error('test hook failed', err); + return Response.json({ error: 'test hook failed' }, { status: 500 }); + } +} diff --git a/app/globals.css b/app/globals.css index 4df7ef7..b362dfa 100644 --- a/app/globals.css +++ b/app/globals.css @@ -214,6 +214,17 @@ body { color: #bfdbfe; } +/* v0.39.0 (ISS-069): maintenance pills on fleet rows — broken = red, in repair = amber. */ +.status-broken { + background: rgba(239, 68, 68, 0.25); + color: #fecaca; +} + +.status-repairing { + background: rgba(245, 158, 11, 0.25); + color: #fde68a; +} + /* v0.37.0 (ISS-073): special-status pill on package rows — sky-blue accent, modeled on .plane-status. */ .special-badge { font-size: 11px; @@ -237,6 +248,26 @@ body { background: #34d399; } +/* v0.39.0 (ISS-069): health bar — modeled on .fuel-bar, amber fill. */ +.health-bar { + height: 4px; + border-radius: 2px; + background: rgba(148, 163, 184, 0.25); + margin: 2px 0 4px; + overflow: hidden; +} + +.health-bar > div { + height: 100%; + border-radius: 2px; + background: #f59e0b; +} + +/* v0.39.0 (ISS-069): the per-plane Repair button sits under the readout row. */ +.repair-btn { + margin-top: 6px; +} + .plane-sub { display: flex; justify-content: space-between; diff --git a/components/LeftPane.tsx b/components/LeftPane.tsx index 12197b1..30b09a7 100644 --- a/components/LeftPane.tsx +++ b/components/LeftPane.tsx @@ -368,17 +368,44 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh )} ) : ( - {p.locationIcao} + <> + {p.locationIcao} + {/* v0.39.0 (ISS-069): maintenance pills — "in repair" while the gate runs, "broken" at zero health. */} + {p.status === 'repairing' && ( + in repair + )} + {p.health <= 0 && ( + broken + )} + )}