Maintenance: distance wear, broken-state burn ×1.5, repair action (ISS-069, v0.39.0)
CI / build + unit + e2e (pull_request) Successful in 9m34s
CI / build + unit + e2e (pull_request) Successful in 9m34s
This commit is contained in:
@@ -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));
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
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<Pick<schema.PlaneRow, 'health' | 'repairUntilSimTime'>> = {};
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user