burnrate 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +507 -0
- package/dist/cli/format.d.ts +13 -0
- package/dist/cli/format.js +319 -0
- package/dist/cli/index.d.ts +7 -0
- package/dist/cli/index.js +1121 -0
- package/dist/cli/setup.d.ts +8 -0
- package/dist/cli/setup.js +143 -0
- package/dist/core/async-engine.d.ts +411 -0
- package/dist/core/async-engine.js +2274 -0
- package/dist/core/async-worldgen.d.ts +19 -0
- package/dist/core/async-worldgen.js +221 -0
- package/dist/core/engine.d.ts +154 -0
- package/dist/core/engine.js +1104 -0
- package/dist/core/pathfinding.d.ts +38 -0
- package/dist/core/pathfinding.js +146 -0
- package/dist/core/types.d.ts +489 -0
- package/dist/core/types.js +359 -0
- package/dist/core/worldgen.d.ts +22 -0
- package/dist/core/worldgen.js +292 -0
- package/dist/db/database.d.ts +83 -0
- package/dist/db/database.js +829 -0
- package/dist/db/turso-database.d.ts +177 -0
- package/dist/db/turso-database.js +1586 -0
- package/dist/mcp/server.d.ts +7 -0
- package/dist/mcp/server.js +1877 -0
- package/dist/server/api.d.ts +8 -0
- package/dist/server/api.js +1234 -0
- package/dist/server/async-tick-server.d.ts +5 -0
- package/dist/server/async-tick-server.js +63 -0
- package/dist/server/errors.d.ts +78 -0
- package/dist/server/errors.js +156 -0
- package/dist/server/rate-limit.d.ts +22 -0
- package/dist/server/rate-limit.js +134 -0
- package/dist/server/tick-server.d.ts +9 -0
- package/dist/server/tick-server.js +114 -0
- package/dist/server/validation.d.ts +194 -0
- package/dist/server/validation.js +114 -0
- package/package.json +65 -0
|
@@ -0,0 +1,1234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BURNRATE REST API Server
|
|
3
|
+
* Hosted game API that MCP clients connect to
|
|
4
|
+
*/
|
|
5
|
+
import { Hono } from 'hono';
|
|
6
|
+
import { cors } from 'hono/cors';
|
|
7
|
+
import { serve } from '@hono/node-server';
|
|
8
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
9
|
+
import { TursoDatabase } from '../db/turso-database.js';
|
|
10
|
+
import { AsyncGameEngine } from '../core/async-engine.js';
|
|
11
|
+
import { generateWorldData, mapRouteNamesToIds } from '../core/async-worldgen.js';
|
|
12
|
+
import { TIER_LIMITS } from '../core/types.js';
|
|
13
|
+
import { GameError, AuthError, ValidationError, NotFoundError, errorResponse, validateBody, ErrorCodes } from './errors.js';
|
|
14
|
+
import { JoinSchema, TravelSchema, ExtractSchema, ProduceSchema, ShipSchema, MarketOrderSchema, ScanSchema, SupplySchema, FactionCreateSchema, ContractCreateSchema } from './validation.js';
|
|
15
|
+
import { rateLimitMiddleware, writeRateLimitMiddleware } from './rate-limit.js';
|
|
16
|
+
let db;
|
|
17
|
+
let engine;
|
|
18
|
+
const app = new Hono();
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// GLOBAL MIDDLEWARE
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// Add request ID to all requests
|
|
23
|
+
app.use('*', async (c, next) => {
|
|
24
|
+
const requestId = uuidv4().slice(0, 8);
|
|
25
|
+
c.set('requestId', requestId);
|
|
26
|
+
c.header('X-Request-ID', requestId);
|
|
27
|
+
await next();
|
|
28
|
+
});
|
|
29
|
+
// CORS
|
|
30
|
+
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || [];
|
|
31
|
+
app.use('*', cors({
|
|
32
|
+
origin: allowedOrigins.length > 0 ? allowedOrigins : '*',
|
|
33
|
+
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
|
34
|
+
allowHeaders: ['Content-Type', 'X-API-Key', 'X-Admin-Key'],
|
|
35
|
+
}));
|
|
36
|
+
// Global rate limit (before auth)
|
|
37
|
+
app.use('*', rateLimitMiddleware({ maxRequests: 100 }));
|
|
38
|
+
// Global error handler
|
|
39
|
+
app.onError((err, c) => {
|
|
40
|
+
const requestId = c.get('requestId');
|
|
41
|
+
return errorResponse(c, err, requestId);
|
|
42
|
+
});
|
|
43
|
+
// ============================================================================
|
|
44
|
+
// AUTH MIDDLEWARE
|
|
45
|
+
// ============================================================================
|
|
46
|
+
const authMiddleware = async (c, next) => {
|
|
47
|
+
const apiKey = c.req.header('X-API-Key');
|
|
48
|
+
if (!apiKey) {
|
|
49
|
+
throw new AuthError(ErrorCodes.MISSING_API_KEY, 'Missing X-API-Key header');
|
|
50
|
+
}
|
|
51
|
+
const player = await db.getPlayerByApiKey(apiKey);
|
|
52
|
+
if (!player) {
|
|
53
|
+
throw new AuthError(ErrorCodes.INVALID_API_KEY, 'Invalid API key');
|
|
54
|
+
}
|
|
55
|
+
c.set('player', player);
|
|
56
|
+
c.set('playerId', player.id);
|
|
57
|
+
await next();
|
|
58
|
+
};
|
|
59
|
+
// Helper to get typed values from context
|
|
60
|
+
const getPlayer = (c) => c.get('player');
|
|
61
|
+
const getPlayerId = (c) => c.get('playerId');
|
|
62
|
+
const getRequestId = (c) => c.get('requestId');
|
|
63
|
+
// ============================================================================
|
|
64
|
+
// PUBLIC ENDPOINTS (no auth required)
|
|
65
|
+
// ============================================================================
|
|
66
|
+
app.get('/', (c) => {
|
|
67
|
+
return c.json({
|
|
68
|
+
name: 'BURNRATE',
|
|
69
|
+
tagline: 'The front doesn\'t feed itself.',
|
|
70
|
+
version: '1.0.0',
|
|
71
|
+
docs: '/docs'
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
app.get('/health', async (c) => {
|
|
75
|
+
try {
|
|
76
|
+
const tick = await db.getCurrentTick();
|
|
77
|
+
return c.json({ status: 'ok', tick });
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
return c.json({ status: 'error', error: String(e) }, 500);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
app.get('/world/status', async (c) => {
|
|
84
|
+
const tick = await db.getCurrentTick();
|
|
85
|
+
const season = await db.getSeasonInfo();
|
|
86
|
+
const zones = await db.getAllZones();
|
|
87
|
+
const factions = await db.getAllFactions();
|
|
88
|
+
return c.json({
|
|
89
|
+
tick,
|
|
90
|
+
season,
|
|
91
|
+
zoneCount: zones.length,
|
|
92
|
+
factionCount: factions.length
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
// Join the game (creates new player)
|
|
96
|
+
app.post('/join', async (c) => {
|
|
97
|
+
const { name } = await validateBody(c, JoinSchema);
|
|
98
|
+
// Check if name taken
|
|
99
|
+
const existing = await db.getPlayerByName(name);
|
|
100
|
+
if (existing) {
|
|
101
|
+
throw new GameError(ErrorCodes.NAME_TAKEN, 'Name already taken', 409);
|
|
102
|
+
}
|
|
103
|
+
// Find a hub to spawn at
|
|
104
|
+
const zones = await db.getAllZones();
|
|
105
|
+
const hub = zones.find(z => z.type === 'hub');
|
|
106
|
+
if (!hub) {
|
|
107
|
+
throw new GameError(ErrorCodes.SERVICE_UNAVAILABLE, 'No spawn point available', 503);
|
|
108
|
+
}
|
|
109
|
+
const player = await db.createPlayer(name, hub.id);
|
|
110
|
+
return c.json({
|
|
111
|
+
success: true,
|
|
112
|
+
message: `Welcome to BURNRATE, ${name}! Save your API key - you'll need it for all requests.`,
|
|
113
|
+
apiKey: player.apiKey,
|
|
114
|
+
playerId: player.id,
|
|
115
|
+
location: hub.name
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
// ============================================================================
|
|
119
|
+
// AUTHENTICATED ENDPOINTS
|
|
120
|
+
// ============================================================================
|
|
121
|
+
// Player status
|
|
122
|
+
app.get('/me', authMiddleware, async (c) => {
|
|
123
|
+
const player = getPlayer(c);
|
|
124
|
+
const zone = await db.getZone(player.locationId);
|
|
125
|
+
const units = await db.getPlayerUnits(player.id);
|
|
126
|
+
const shipments = await db.getPlayerShipments(player.id);
|
|
127
|
+
let faction = null;
|
|
128
|
+
if (player.factionId) {
|
|
129
|
+
faction = await db.getFaction(player.factionId);
|
|
130
|
+
}
|
|
131
|
+
return c.json({
|
|
132
|
+
id: player.id,
|
|
133
|
+
name: player.name,
|
|
134
|
+
tier: player.tier,
|
|
135
|
+
inventory: player.inventory,
|
|
136
|
+
location: zone ? { id: zone.id, name: zone.name, type: zone.type } : null,
|
|
137
|
+
faction: faction ? { id: faction.id, name: faction.name, tag: faction.tag } : null,
|
|
138
|
+
reputation: player.reputation,
|
|
139
|
+
actionsToday: player.actionsToday,
|
|
140
|
+
units: units.length,
|
|
141
|
+
activeShipments: shipments.filter(s => s.status === 'in_transit').length
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
// View world map
|
|
145
|
+
app.get('/world/zones', authMiddleware, async (c) => {
|
|
146
|
+
const zones = await db.getAllZones();
|
|
147
|
+
return c.json(zones.map(z => ({
|
|
148
|
+
id: z.id,
|
|
149
|
+
name: z.name,
|
|
150
|
+
type: z.type,
|
|
151
|
+
ownerId: z.ownerId,
|
|
152
|
+
supplyLevel: z.supplyLevel,
|
|
153
|
+
burnRate: z.burnRate
|
|
154
|
+
})));
|
|
155
|
+
});
|
|
156
|
+
// View zone details
|
|
157
|
+
app.get('/world/zones/:id', authMiddleware, async (c) => {
|
|
158
|
+
const zone = await db.getZone(c.req.param('id'));
|
|
159
|
+
if (!zone) {
|
|
160
|
+
return c.json({ error: 'Zone not found' }, 404);
|
|
161
|
+
}
|
|
162
|
+
let owner = null;
|
|
163
|
+
if (zone.ownerId) {
|
|
164
|
+
owner = await db.getFaction(zone.ownerId);
|
|
165
|
+
}
|
|
166
|
+
const routes = await db.getRoutesFromZone(zone.id);
|
|
167
|
+
const orders = await db.getOrdersForZone(zone.id);
|
|
168
|
+
const unitsForSale = await db.getUnitsForSaleAtZone(zone.id);
|
|
169
|
+
return c.json({
|
|
170
|
+
...zone,
|
|
171
|
+
owner: owner ? { name: owner.name, tag: owner.tag } : null,
|
|
172
|
+
connections: routes.length,
|
|
173
|
+
marketOrders: orders.length,
|
|
174
|
+
unitsForSale: unitsForSale.length
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
// Get routes from current location or specified zone
|
|
178
|
+
app.get('/routes', authMiddleware, async (c) => {
|
|
179
|
+
const player = getPlayer(c);
|
|
180
|
+
const fromZoneId = c.req.query('from') || player.locationId;
|
|
181
|
+
const routes = await db.getRoutesFromZone(fromZoneId);
|
|
182
|
+
const enriched = await Promise.all(routes.map(async (r) => {
|
|
183
|
+
const toZone = await db.getZone(r.toZoneId);
|
|
184
|
+
return {
|
|
185
|
+
id: r.id,
|
|
186
|
+
to: { id: r.toZoneId, name: toZone?.name, type: toZone?.type },
|
|
187
|
+
distance: r.distance,
|
|
188
|
+
risk: r.baseRisk,
|
|
189
|
+
chokepointRating: r.chokepointRating
|
|
190
|
+
};
|
|
191
|
+
}));
|
|
192
|
+
return c.json(enriched);
|
|
193
|
+
});
|
|
194
|
+
// Travel to adjacent zone
|
|
195
|
+
app.post('/travel', authMiddleware, writeRateLimitMiddleware(), async (c) => {
|
|
196
|
+
const playerId = getPlayerId(c);
|
|
197
|
+
const { to } = await validateBody(c, TravelSchema);
|
|
198
|
+
const result = await engine.travel(playerId, to);
|
|
199
|
+
if (!result.success) {
|
|
200
|
+
throw new GameError(ErrorCodes.NO_ROUTE, result.error || 'Travel failed');
|
|
201
|
+
}
|
|
202
|
+
const zone = await db.getZone(to);
|
|
203
|
+
return c.json({
|
|
204
|
+
success: true,
|
|
205
|
+
location: { id: zone?.id, name: zone?.name, type: zone?.type }
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
// Extract resources at a Field
|
|
209
|
+
app.post('/extract', authMiddleware, writeRateLimitMiddleware(), async (c) => {
|
|
210
|
+
const playerId = getPlayerId(c);
|
|
211
|
+
const { quantity } = await validateBody(c, ExtractSchema);
|
|
212
|
+
const result = await engine.extract(playerId, quantity);
|
|
213
|
+
if (!result.success) {
|
|
214
|
+
throw new GameError(ErrorCodes.WRONG_ZONE_TYPE, result.error || 'Extraction failed');
|
|
215
|
+
}
|
|
216
|
+
return c.json({ success: true, extracted: result.extracted });
|
|
217
|
+
});
|
|
218
|
+
// Produce resources/units at a Factory
|
|
219
|
+
app.post('/produce', authMiddleware, writeRateLimitMiddleware(), async (c) => {
|
|
220
|
+
const playerId = getPlayerId(c);
|
|
221
|
+
const { output, quantity } = await validateBody(c, ProduceSchema);
|
|
222
|
+
const result = await engine.produce(playerId, output, quantity);
|
|
223
|
+
if (!result.success) {
|
|
224
|
+
throw new GameError(ErrorCodes.INSUFFICIENT_RESOURCES, result.error || 'Production failed');
|
|
225
|
+
}
|
|
226
|
+
return c.json({
|
|
227
|
+
success: true,
|
|
228
|
+
produced: result.produced,
|
|
229
|
+
units: result.units
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
// Create shipment
|
|
233
|
+
app.post('/ship', authMiddleware, writeRateLimitMiddleware(), async (c) => {
|
|
234
|
+
const playerId = getPlayerId(c);
|
|
235
|
+
const { type, path, cargo } = await validateBody(c, ShipSchema);
|
|
236
|
+
const result = await engine.createShipmentWithPath(playerId, type, path, cargo);
|
|
237
|
+
if (!result.success) {
|
|
238
|
+
throw new GameError(ErrorCodes.INSUFFICIENT_RESOURCES, result.error || 'Shipment failed');
|
|
239
|
+
}
|
|
240
|
+
return c.json({ success: true, shipment: result.shipment });
|
|
241
|
+
});
|
|
242
|
+
// Get player's shipments
|
|
243
|
+
app.get('/shipments', authMiddleware, async (c) => {
|
|
244
|
+
const playerId = getPlayerId(c);
|
|
245
|
+
const shipments = await db.getPlayerShipments(playerId);
|
|
246
|
+
return c.json(shipments.map(s => ({
|
|
247
|
+
id: s.id,
|
|
248
|
+
type: s.type,
|
|
249
|
+
status: s.status,
|
|
250
|
+
path: s.path,
|
|
251
|
+
currentPosition: s.currentPosition,
|
|
252
|
+
ticksToNextZone: s.ticksToNextZone,
|
|
253
|
+
cargo: s.cargo
|
|
254
|
+
})));
|
|
255
|
+
});
|
|
256
|
+
// Market: place order
|
|
257
|
+
app.post('/market/order', authMiddleware, writeRateLimitMiddleware(), async (c) => {
|
|
258
|
+
const playerId = getPlayerId(c);
|
|
259
|
+
const { resource, side, price, quantity } = await validateBody(c, MarketOrderSchema);
|
|
260
|
+
const player = await db.getPlayer(playerId);
|
|
261
|
+
if (!player) {
|
|
262
|
+
throw new NotFoundError('Player', playerId);
|
|
263
|
+
}
|
|
264
|
+
const result = await engine.placeOrder(playerId, player.locationId, resource, side, price, quantity);
|
|
265
|
+
if (!result.success) {
|
|
266
|
+
throw new GameError(ErrorCodes.INSUFFICIENT_RESOURCES, result.error || 'Order failed');
|
|
267
|
+
}
|
|
268
|
+
return c.json({ success: true, order: result.order });
|
|
269
|
+
});
|
|
270
|
+
// Market: view orders at current location
|
|
271
|
+
app.get('/market/orders', authMiddleware, async (c) => {
|
|
272
|
+
const player = getPlayer(c);
|
|
273
|
+
const resource = c.req.query('resource');
|
|
274
|
+
const orders = await db.getOrdersForZone(player.locationId, resource || undefined);
|
|
275
|
+
return c.json(orders.map(o => ({
|
|
276
|
+
id: o.id,
|
|
277
|
+
resource: o.resource,
|
|
278
|
+
side: o.side,
|
|
279
|
+
price: o.price,
|
|
280
|
+
quantity: o.quantity,
|
|
281
|
+
playerId: o.playerId
|
|
282
|
+
})));
|
|
283
|
+
});
|
|
284
|
+
// Scan zone or route for intel
|
|
285
|
+
app.post('/scan', authMiddleware, writeRateLimitMiddleware(), async (c) => {
|
|
286
|
+
const playerId = getPlayerId(c);
|
|
287
|
+
const { targetType, targetId } = await validateBody(c, ScanSchema);
|
|
288
|
+
const result = await engine.scan(playerId, targetType, targetId);
|
|
289
|
+
if (!result.success) {
|
|
290
|
+
throw new NotFoundError(targetType === 'zone' ? 'Zone' : 'Route', targetId);
|
|
291
|
+
}
|
|
292
|
+
return c.json({ success: true, intel: result.intel });
|
|
293
|
+
});
|
|
294
|
+
// Supply zone with SU
|
|
295
|
+
app.post('/supply', authMiddleware, writeRateLimitMiddleware(), async (c) => {
|
|
296
|
+
const playerId = getPlayerId(c);
|
|
297
|
+
const { amount } = await validateBody(c, SupplySchema);
|
|
298
|
+
const player = await db.getPlayer(playerId);
|
|
299
|
+
if (!player) {
|
|
300
|
+
throw new NotFoundError('Player', playerId);
|
|
301
|
+
}
|
|
302
|
+
const result = await engine.depositSU(playerId, player.locationId, amount);
|
|
303
|
+
if (!result.success) {
|
|
304
|
+
throw new GameError(ErrorCodes.INSUFFICIENT_RESOURCES, result.error || 'Supply failed');
|
|
305
|
+
}
|
|
306
|
+
return c.json({ success: true });
|
|
307
|
+
});
|
|
308
|
+
// Capture zone for faction
|
|
309
|
+
app.post('/capture', authMiddleware, async (c) => {
|
|
310
|
+
const playerId = getPlayerId(c);
|
|
311
|
+
const player = await db.getPlayer(playerId);
|
|
312
|
+
if (!player) {
|
|
313
|
+
return c.json({ error: 'Player not found' }, 404);
|
|
314
|
+
}
|
|
315
|
+
const result = await engine.captureZone(playerId, player.locationId);
|
|
316
|
+
if (!result.success) {
|
|
317
|
+
return c.json({ error: result.error }, 400);
|
|
318
|
+
}
|
|
319
|
+
return c.json({ success: true });
|
|
320
|
+
});
|
|
321
|
+
// Deposit medkits or comms to zone stockpile
|
|
322
|
+
app.post('/stockpile', authMiddleware, writeRateLimitMiddleware(), async (c) => {
|
|
323
|
+
const playerId = getPlayerId(c);
|
|
324
|
+
const body = await c.req.json();
|
|
325
|
+
const resource = body.resource;
|
|
326
|
+
const amount = body.amount;
|
|
327
|
+
if (!resource || !['medkits', 'comms'].includes(resource)) {
|
|
328
|
+
throw new ValidationError(`resource must be 'medkits' or 'comms'`);
|
|
329
|
+
}
|
|
330
|
+
if (!amount || amount < 1 || !Number.isInteger(amount)) {
|
|
331
|
+
throw new ValidationError('amount must be a positive integer');
|
|
332
|
+
}
|
|
333
|
+
const player = await db.getPlayer(playerId);
|
|
334
|
+
if (!player)
|
|
335
|
+
throw new NotFoundError('Player', playerId);
|
|
336
|
+
const result = await engine.depositStockpile(playerId, player.locationId, resource, amount);
|
|
337
|
+
if (!result.success) {
|
|
338
|
+
throw new GameError(ErrorCodes.INSUFFICIENT_RESOURCES, result.error || 'Stockpile deposit failed');
|
|
339
|
+
}
|
|
340
|
+
return c.json({ success: true });
|
|
341
|
+
});
|
|
342
|
+
// Get zone efficiency details
|
|
343
|
+
app.get('/zone/:zoneId/efficiency', authMiddleware, async (c) => {
|
|
344
|
+
const zoneId = c.req.param('zoneId');
|
|
345
|
+
const result = await engine.getZoneEfficiency(zoneId);
|
|
346
|
+
if (!result.success) {
|
|
347
|
+
throw new NotFoundError('Zone', zoneId);
|
|
348
|
+
}
|
|
349
|
+
return c.json({ success: true, efficiency: result.efficiency });
|
|
350
|
+
});
|
|
351
|
+
// ============================================================================
|
|
352
|
+
// UNITS
|
|
353
|
+
// ============================================================================
|
|
354
|
+
app.get('/units', authMiddleware, async (c) => {
|
|
355
|
+
const playerId = getPlayerId(c);
|
|
356
|
+
const units = await db.getPlayerUnits(playerId);
|
|
357
|
+
return c.json(units.map(u => ({
|
|
358
|
+
id: u.id,
|
|
359
|
+
type: u.type,
|
|
360
|
+
locationId: u.locationId,
|
|
361
|
+
strength: u.strength,
|
|
362
|
+
maintenance: u.maintenance,
|
|
363
|
+
assignmentId: u.assignmentId,
|
|
364
|
+
forSalePrice: u.forSalePrice
|
|
365
|
+
})));
|
|
366
|
+
});
|
|
367
|
+
app.post('/units/:id/escort', authMiddleware, async (c) => {
|
|
368
|
+
const playerId = getPlayerId(c);
|
|
369
|
+
const unitId = c.req.param('id');
|
|
370
|
+
const body = await c.req.json();
|
|
371
|
+
const { shipmentId } = body;
|
|
372
|
+
if (!shipmentId) {
|
|
373
|
+
return c.json({ error: 'Missing shipmentId' }, 400);
|
|
374
|
+
}
|
|
375
|
+
const result = await engine.assignEscort(playerId, unitId, shipmentId);
|
|
376
|
+
if (!result.success) {
|
|
377
|
+
return c.json({ error: result.error }, 400);
|
|
378
|
+
}
|
|
379
|
+
return c.json({ success: true });
|
|
380
|
+
});
|
|
381
|
+
app.post('/units/:id/raider', authMiddleware, async (c) => {
|
|
382
|
+
const playerId = getPlayerId(c);
|
|
383
|
+
const unitId = c.req.param('id');
|
|
384
|
+
const body = await c.req.json();
|
|
385
|
+
const { routeId } = body;
|
|
386
|
+
if (!routeId) {
|
|
387
|
+
return c.json({ error: 'Missing routeId' }, 400);
|
|
388
|
+
}
|
|
389
|
+
const result = await engine.deployRaider(playerId, unitId, routeId);
|
|
390
|
+
if (!result.success) {
|
|
391
|
+
return c.json({ error: result.error }, 400);
|
|
392
|
+
}
|
|
393
|
+
return c.json({ success: true });
|
|
394
|
+
});
|
|
395
|
+
app.post('/units/:id/sell', authMiddleware, async (c) => {
|
|
396
|
+
const playerId = getPlayerId(c);
|
|
397
|
+
const unitId = c.req.param('id');
|
|
398
|
+
const body = await c.req.json();
|
|
399
|
+
const { price } = body;
|
|
400
|
+
if (!price || price < 1) {
|
|
401
|
+
return c.json({ error: 'Invalid price' }, 400);
|
|
402
|
+
}
|
|
403
|
+
const result = await engine.listUnitForSale(playerId, unitId, price);
|
|
404
|
+
if (!result.success) {
|
|
405
|
+
return c.json({ error: result.error }, 400);
|
|
406
|
+
}
|
|
407
|
+
return c.json({ success: true });
|
|
408
|
+
});
|
|
409
|
+
app.delete('/units/:id/sell', authMiddleware, async (c) => {
|
|
410
|
+
const playerId = getPlayerId(c);
|
|
411
|
+
const unitId = c.req.param('id');
|
|
412
|
+
const result = await engine.unlistUnit(playerId, unitId);
|
|
413
|
+
if (!result.success) {
|
|
414
|
+
return c.json({ error: result.error }, 400);
|
|
415
|
+
}
|
|
416
|
+
return c.json({ success: true });
|
|
417
|
+
});
|
|
418
|
+
app.post('/hire/:unitId', authMiddleware, async (c) => {
|
|
419
|
+
const playerId = getPlayerId(c);
|
|
420
|
+
const unitId = c.req.param('unitId');
|
|
421
|
+
const result = await engine.hireUnit(playerId, unitId);
|
|
422
|
+
if (!result.success) {
|
|
423
|
+
return c.json({ error: result.error }, 400);
|
|
424
|
+
}
|
|
425
|
+
return c.json({ success: true, unit: result.unit });
|
|
426
|
+
});
|
|
427
|
+
// Units for sale at current location
|
|
428
|
+
app.get('/market/units', authMiddleware, async (c) => {
|
|
429
|
+
const player = getPlayer(c);
|
|
430
|
+
const units = await db.getUnitsForSaleAtZone(player.locationId);
|
|
431
|
+
return c.json(units.map(u => ({
|
|
432
|
+
id: u.id,
|
|
433
|
+
type: u.type,
|
|
434
|
+
strength: u.strength,
|
|
435
|
+
maintenance: u.maintenance,
|
|
436
|
+
price: u.forSalePrice,
|
|
437
|
+
sellerId: u.playerId
|
|
438
|
+
})));
|
|
439
|
+
});
|
|
440
|
+
// ============================================================================
|
|
441
|
+
// FACTIONS
|
|
442
|
+
// ============================================================================
|
|
443
|
+
app.get('/factions', authMiddleware, async (c) => {
|
|
444
|
+
const factions = await db.getAllFactions();
|
|
445
|
+
return c.json(factions.map(f => ({
|
|
446
|
+
id: f.id,
|
|
447
|
+
name: f.name,
|
|
448
|
+
tag: f.tag,
|
|
449
|
+
memberCount: f.members.length,
|
|
450
|
+
zoneCount: f.controlledZones.length
|
|
451
|
+
})));
|
|
452
|
+
});
|
|
453
|
+
app.post('/factions', authMiddleware, writeRateLimitMiddleware(), async (c) => {
|
|
454
|
+
const playerId = getPlayerId(c);
|
|
455
|
+
const { name, tag } = await validateBody(c, FactionCreateSchema);
|
|
456
|
+
const result = await engine.createFaction(playerId, name, tag);
|
|
457
|
+
if (!result.success) {
|
|
458
|
+
throw new GameError(ErrorCodes.ALREADY_IN_FACTION, result.error || 'Faction creation failed');
|
|
459
|
+
}
|
|
460
|
+
return c.json({ success: true, faction: result.faction });
|
|
461
|
+
});
|
|
462
|
+
app.post('/factions/:id/join', authMiddleware, async (c) => {
|
|
463
|
+
const playerId = getPlayerId(c);
|
|
464
|
+
const factionId = c.req.param('id');
|
|
465
|
+
const result = await engine.joinFaction(playerId, factionId);
|
|
466
|
+
if (!result.success) {
|
|
467
|
+
return c.json({ error: result.error }, 400);
|
|
468
|
+
}
|
|
469
|
+
return c.json({ success: true });
|
|
470
|
+
});
|
|
471
|
+
app.post('/factions/leave', authMiddleware, async (c) => {
|
|
472
|
+
const playerId = getPlayerId(c);
|
|
473
|
+
const result = await engine.leaveFaction(playerId);
|
|
474
|
+
if (!result.success) {
|
|
475
|
+
return c.json({ error: result.error }, 400);
|
|
476
|
+
}
|
|
477
|
+
return c.json({ success: true });
|
|
478
|
+
});
|
|
479
|
+
app.get('/factions/intel', authMiddleware, async (c) => {
|
|
480
|
+
const playerId = getPlayerId(c);
|
|
481
|
+
const result = await engine.getFactionIntel(playerId);
|
|
482
|
+
if (!result.success) {
|
|
483
|
+
return c.json({ error: result.error }, 400);
|
|
484
|
+
}
|
|
485
|
+
return c.json({ success: true, intel: result.intel });
|
|
486
|
+
});
|
|
487
|
+
// Get detailed faction info for members
|
|
488
|
+
app.get('/factions/mine', authMiddleware, async (c) => {
|
|
489
|
+
const playerId = getPlayerId(c);
|
|
490
|
+
const result = await engine.getFactionDetails(playerId);
|
|
491
|
+
if (!result.success) {
|
|
492
|
+
throw new GameError(ErrorCodes.NOT_IN_FACTION, result.error || 'Not in a faction');
|
|
493
|
+
}
|
|
494
|
+
return c.json({
|
|
495
|
+
success: true,
|
|
496
|
+
faction: {
|
|
497
|
+
id: result.faction?.id,
|
|
498
|
+
name: result.faction?.name,
|
|
499
|
+
tag: result.faction?.tag,
|
|
500
|
+
treasury: result.faction?.treasury,
|
|
501
|
+
members: result.faction?.members,
|
|
502
|
+
controlledZones: result.faction?.controlledZones,
|
|
503
|
+
upgrades: result.faction?.upgrades,
|
|
504
|
+
officerWithdrawLimit: result.faction?.officerWithdrawLimit
|
|
505
|
+
},
|
|
506
|
+
myRank: result.myRank
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
// Promote a faction member
|
|
510
|
+
app.post('/factions/members/:id/promote', authMiddleware, writeRateLimitMiddleware(), async (c) => {
|
|
511
|
+
const playerId = getPlayerId(c);
|
|
512
|
+
const targetPlayerId = c.req.param('id');
|
|
513
|
+
const body = await c.req.json();
|
|
514
|
+
const newRank = body.rank || 'officer';
|
|
515
|
+
const result = await engine.promoteFactionMember(playerId, targetPlayerId, newRank);
|
|
516
|
+
if (!result.success) {
|
|
517
|
+
throw new GameError(ErrorCodes.INSUFFICIENT_PERMISSION, result.error || 'Promotion failed');
|
|
518
|
+
}
|
|
519
|
+
return c.json({ success: true });
|
|
520
|
+
});
|
|
521
|
+
// Demote a faction member
|
|
522
|
+
app.post('/factions/members/:id/demote', authMiddleware, writeRateLimitMiddleware(), async (c) => {
|
|
523
|
+
const playerId = getPlayerId(c);
|
|
524
|
+
const targetPlayerId = c.req.param('id');
|
|
525
|
+
const body = await c.req.json();
|
|
526
|
+
const newRank = body.rank || 'member';
|
|
527
|
+
const result = await engine.demoteFactionMember(playerId, targetPlayerId, newRank);
|
|
528
|
+
if (!result.success) {
|
|
529
|
+
throw new GameError(ErrorCodes.INSUFFICIENT_PERMISSION, result.error || 'Demotion failed');
|
|
530
|
+
}
|
|
531
|
+
return c.json({ success: true });
|
|
532
|
+
});
|
|
533
|
+
// Kick a faction member
|
|
534
|
+
app.delete('/factions/members/:id', authMiddleware, writeRateLimitMiddleware(), async (c) => {
|
|
535
|
+
const playerId = getPlayerId(c);
|
|
536
|
+
const targetPlayerId = c.req.param('id');
|
|
537
|
+
const result = await engine.kickFactionMember(playerId, targetPlayerId);
|
|
538
|
+
if (!result.success) {
|
|
539
|
+
throw new GameError(ErrorCodes.INSUFFICIENT_PERMISSION, result.error || 'Kick failed');
|
|
540
|
+
}
|
|
541
|
+
return c.json({ success: true });
|
|
542
|
+
});
|
|
543
|
+
// Transfer faction leadership
|
|
544
|
+
app.post('/factions/transfer-leadership', authMiddleware, writeRateLimitMiddleware(), async (c) => {
|
|
545
|
+
const playerId = getPlayerId(c);
|
|
546
|
+
const body = await c.req.json();
|
|
547
|
+
const { targetPlayerId } = body;
|
|
548
|
+
if (!targetPlayerId) {
|
|
549
|
+
throw new ValidationError('Missing targetPlayerId');
|
|
550
|
+
}
|
|
551
|
+
const result = await engine.transferFactionLeadership(playerId, targetPlayerId);
|
|
552
|
+
if (!result.success) {
|
|
553
|
+
throw new GameError(ErrorCodes.INSUFFICIENT_PERMISSION, result.error || 'Transfer failed');
|
|
554
|
+
}
|
|
555
|
+
return c.json({ success: true });
|
|
556
|
+
});
|
|
557
|
+
// Deposit to faction treasury
|
|
558
|
+
app.post('/factions/treasury/deposit', authMiddleware, writeRateLimitMiddleware(), async (c) => {
|
|
559
|
+
const playerId = getPlayerId(c);
|
|
560
|
+
const body = await c.req.json();
|
|
561
|
+
const { resources } = body;
|
|
562
|
+
if (!resources || typeof resources !== 'object') {
|
|
563
|
+
throw new ValidationError('Missing or invalid resources');
|
|
564
|
+
}
|
|
565
|
+
const result = await engine.depositToTreasury(playerId, resources);
|
|
566
|
+
if (!result.success) {
|
|
567
|
+
throw new GameError(ErrorCodes.INSUFFICIENT_RESOURCES, result.error || 'Deposit failed');
|
|
568
|
+
}
|
|
569
|
+
return c.json({ success: true });
|
|
570
|
+
});
|
|
571
|
+
// Withdraw from faction treasury
|
|
572
|
+
app.post('/factions/treasury/withdraw', authMiddleware, writeRateLimitMiddleware(), async (c) => {
|
|
573
|
+
const playerId = getPlayerId(c);
|
|
574
|
+
const body = await c.req.json();
|
|
575
|
+
const { resources } = body;
|
|
576
|
+
if (!resources || typeof resources !== 'object') {
|
|
577
|
+
throw new ValidationError('Missing or invalid resources');
|
|
578
|
+
}
|
|
579
|
+
const result = await engine.withdrawFromTreasury(playerId, resources);
|
|
580
|
+
if (!result.success) {
|
|
581
|
+
throw new GameError(ErrorCodes.INSUFFICIENT_PERMISSION, result.error || 'Withdrawal failed');
|
|
582
|
+
}
|
|
583
|
+
return c.json({ success: true });
|
|
584
|
+
});
|
|
585
|
+
// ============================================================================
|
|
586
|
+
// SEASONS & LEADERBOARDS
|
|
587
|
+
// ============================================================================
|
|
588
|
+
// Get current season status
|
|
589
|
+
app.get('/season', authMiddleware, async (c) => {
|
|
590
|
+
const status = await engine.getSeasonStatus();
|
|
591
|
+
return c.json(status);
|
|
592
|
+
});
|
|
593
|
+
// Get season leaderboard
|
|
594
|
+
app.get('/leaderboard', authMiddleware, async (c) => {
|
|
595
|
+
const season = c.req.query('season') ? parseInt(c.req.query('season')) : undefined;
|
|
596
|
+
const type = c.req.query('type');
|
|
597
|
+
const limit = Math.min(parseInt(c.req.query('limit') || '50'), 100);
|
|
598
|
+
const result = await engine.getLeaderboard(season, type, limit);
|
|
599
|
+
if (!result.success) {
|
|
600
|
+
throw new GameError(ErrorCodes.INTERNAL_ERROR, result.error || 'Failed to get leaderboard');
|
|
601
|
+
}
|
|
602
|
+
return c.json({
|
|
603
|
+
season: result.season,
|
|
604
|
+
leaderboard: result.leaderboard
|
|
605
|
+
});
|
|
606
|
+
});
|
|
607
|
+
// Get player's season score and rank
|
|
608
|
+
app.get('/season/me', authMiddleware, async (c) => {
|
|
609
|
+
const playerId = getPlayerId(c);
|
|
610
|
+
const season = c.req.query('season') ? parseInt(c.req.query('season')) : undefined;
|
|
611
|
+
const result = await engine.getPlayerSeasonScore(playerId, season);
|
|
612
|
+
if (!result.success) {
|
|
613
|
+
throw new NotFoundError('Player', playerId);
|
|
614
|
+
}
|
|
615
|
+
return c.json({
|
|
616
|
+
score: result.score,
|
|
617
|
+
rank: result.rank
|
|
618
|
+
});
|
|
619
|
+
});
|
|
620
|
+
// ============================================================================
|
|
621
|
+
// EVENTS (history with tier limits)
|
|
622
|
+
// ============================================================================
|
|
623
|
+
// Get player's event history (limited by tier)
|
|
624
|
+
app.get('/events', authMiddleware, async (c) => {
|
|
625
|
+
const player = getPlayer(c);
|
|
626
|
+
const typeFilter = c.req.query('type');
|
|
627
|
+
const requestedLimit = parseInt(c.req.query('limit') || '100');
|
|
628
|
+
// Apply tier-based event history limit
|
|
629
|
+
const tierLimit = TIER_LIMITS[player.tier].eventHistory;
|
|
630
|
+
const effectiveLimit = Math.min(requestedLimit, tierLimit);
|
|
631
|
+
const events = await db.getEvents({
|
|
632
|
+
actorId: player.id,
|
|
633
|
+
type: typeFilter || undefined,
|
|
634
|
+
limit: effectiveLimit
|
|
635
|
+
});
|
|
636
|
+
return c.json({
|
|
637
|
+
events,
|
|
638
|
+
limit: effectiveLimit,
|
|
639
|
+
tierLimit,
|
|
640
|
+
message: effectiveLimit < requestedLimit
|
|
641
|
+
? `Event history limited to ${tierLimit} for ${player.tier} tier`
|
|
642
|
+
: undefined
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
// ============================================================================
|
|
646
|
+
// REPUTATION
|
|
647
|
+
// ============================================================================
|
|
648
|
+
// Get reputation details
|
|
649
|
+
app.get('/reputation', authMiddleware, async (c) => {
|
|
650
|
+
const playerId = getPlayerId(c);
|
|
651
|
+
const result = await engine.getReputationDetails(playerId);
|
|
652
|
+
if (!result.success) {
|
|
653
|
+
throw new NotFoundError('Player', playerId);
|
|
654
|
+
}
|
|
655
|
+
return c.json({
|
|
656
|
+
success: true,
|
|
657
|
+
reputation: result.reputation,
|
|
658
|
+
title: result.title,
|
|
659
|
+
nextTitle: result.nextTitle
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
// ============================================================================
|
|
663
|
+
// LICENSES
|
|
664
|
+
// ============================================================================
|
|
665
|
+
// Get license status
|
|
666
|
+
app.get('/licenses', authMiddleware, async (c) => {
|
|
667
|
+
const playerId = getPlayerId(c);
|
|
668
|
+
const result = await engine.getLicenseStatus(playerId);
|
|
669
|
+
if (!result.success) {
|
|
670
|
+
throw new NotFoundError('Player', playerId);
|
|
671
|
+
}
|
|
672
|
+
return c.json({ success: true, licenses: result.licenses });
|
|
673
|
+
});
|
|
674
|
+
// Unlock a license
|
|
675
|
+
app.post('/licenses/:type/unlock', authMiddleware, writeRateLimitMiddleware(), async (c) => {
|
|
676
|
+
const playerId = getPlayerId(c);
|
|
677
|
+
const licenseType = c.req.param('type');
|
|
678
|
+
if (!['courier', 'freight', 'convoy'].includes(licenseType)) {
|
|
679
|
+
throw new ValidationError('Invalid license type');
|
|
680
|
+
}
|
|
681
|
+
const result = await engine.unlockLicense(playerId, licenseType);
|
|
682
|
+
if (!result.success) {
|
|
683
|
+
throw new GameError(ErrorCodes.LICENSE_REQUIRED, result.error || 'Cannot unlock license');
|
|
684
|
+
}
|
|
685
|
+
return c.json({ success: true });
|
|
686
|
+
});
|
|
687
|
+
// ============================================================================
|
|
688
|
+
// INTEL
|
|
689
|
+
// ============================================================================
|
|
690
|
+
// Get player's personal intel with freshness decay
|
|
691
|
+
app.get('/intel', authMiddleware, async (c) => {
|
|
692
|
+
const playerId = getPlayerId(c);
|
|
693
|
+
const limit = parseInt(c.req.query('limit') || '100');
|
|
694
|
+
const result = await engine.getPlayerIntel(playerId, Math.min(limit, 500));
|
|
695
|
+
if (!result.success) {
|
|
696
|
+
throw new NotFoundError('Player', playerId);
|
|
697
|
+
}
|
|
698
|
+
return c.json({
|
|
699
|
+
success: true,
|
|
700
|
+
intel: result.intel?.map(i => ({
|
|
701
|
+
id: i.id,
|
|
702
|
+
targetType: i.targetType,
|
|
703
|
+
targetId: i.targetId,
|
|
704
|
+
gatheredAt: i.gatheredAt,
|
|
705
|
+
freshness: i.freshness,
|
|
706
|
+
ageInTicks: i.ageInTicks,
|
|
707
|
+
effectiveSignalQuality: i.effectiveSignalQuality,
|
|
708
|
+
data: i.data
|
|
709
|
+
}))
|
|
710
|
+
});
|
|
711
|
+
});
|
|
712
|
+
// Get intel on a specific target (zone or route)
|
|
713
|
+
app.get('/intel/:targetType/:targetId', authMiddleware, async (c) => {
|
|
714
|
+
const playerId = getPlayerId(c);
|
|
715
|
+
const targetType = c.req.param('targetType');
|
|
716
|
+
const targetId = c.req.param('targetId');
|
|
717
|
+
if (targetType !== 'zone' && targetType !== 'route') {
|
|
718
|
+
throw new ValidationError('Invalid target type. Must be "zone" or "route".');
|
|
719
|
+
}
|
|
720
|
+
const result = await engine.getTargetIntel(playerId, targetType, targetId);
|
|
721
|
+
if (!result.success) {
|
|
722
|
+
throw new NotFoundError('Player', playerId);
|
|
723
|
+
}
|
|
724
|
+
if (!result.intel) {
|
|
725
|
+
return c.json({
|
|
726
|
+
success: true,
|
|
727
|
+
intel: null,
|
|
728
|
+
message: 'No intel available. Scan this target to gather intel.'
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
return c.json({
|
|
732
|
+
success: true,
|
|
733
|
+
intel: {
|
|
734
|
+
id: result.intel.id,
|
|
735
|
+
targetType: result.intel.targetType,
|
|
736
|
+
targetId: result.intel.targetId,
|
|
737
|
+
gatheredAt: result.intel.gatheredAt,
|
|
738
|
+
freshness: result.intel.freshness,
|
|
739
|
+
ageInTicks: result.intel.ageInTicks,
|
|
740
|
+
effectiveSignalQuality: result.intel.effectiveSignalQuality,
|
|
741
|
+
data: result.intel.data
|
|
742
|
+
}
|
|
743
|
+
});
|
|
744
|
+
});
|
|
745
|
+
// ============================================================================
|
|
746
|
+
// CONTRACTS
|
|
747
|
+
// ============================================================================
|
|
748
|
+
// List open contracts
|
|
749
|
+
app.get('/contracts', authMiddleware, async (c) => {
|
|
750
|
+
const contracts = await db.getOpenContracts();
|
|
751
|
+
const tick = await db.getCurrentTick();
|
|
752
|
+
return c.json(contracts.map(contract => ({
|
|
753
|
+
id: contract.id,
|
|
754
|
+
type: contract.type,
|
|
755
|
+
details: contract.details,
|
|
756
|
+
deadline: contract.deadline,
|
|
757
|
+
ticksRemaining: contract.deadline - tick,
|
|
758
|
+
reward: contract.reward,
|
|
759
|
+
bonus: contract.bonus,
|
|
760
|
+
status: contract.status,
|
|
761
|
+
posterId: contract.posterId
|
|
762
|
+
})));
|
|
763
|
+
});
|
|
764
|
+
// Get player's contracts (posted and accepted)
|
|
765
|
+
app.get('/contracts/mine', authMiddleware, async (c) => {
|
|
766
|
+
const playerId = getPlayerId(c);
|
|
767
|
+
const result = await engine.getMyContracts(playerId);
|
|
768
|
+
if (!result.success) {
|
|
769
|
+
throw new NotFoundError('Player', playerId);
|
|
770
|
+
}
|
|
771
|
+
const tick = await db.getCurrentTick();
|
|
772
|
+
return c.json(result.contracts?.map(contract => ({
|
|
773
|
+
id: contract.id,
|
|
774
|
+
type: contract.type,
|
|
775
|
+
details: contract.details,
|
|
776
|
+
deadline: contract.deadline,
|
|
777
|
+
ticksRemaining: contract.deadline - tick,
|
|
778
|
+
reward: contract.reward,
|
|
779
|
+
bonus: contract.bonus,
|
|
780
|
+
status: contract.status,
|
|
781
|
+
posterId: contract.posterId,
|
|
782
|
+
acceptedBy: contract.acceptedBy,
|
|
783
|
+
isMyContract: contract.posterId === playerId,
|
|
784
|
+
iAccepted: contract.acceptedBy === playerId
|
|
785
|
+
})));
|
|
786
|
+
});
|
|
787
|
+
// Create a contract
|
|
788
|
+
app.post('/contracts', authMiddleware, writeRateLimitMiddleware(), async (c) => {
|
|
789
|
+
const playerId = getPlayerId(c);
|
|
790
|
+
const body = await validateBody(c, ContractCreateSchema);
|
|
791
|
+
const result = await engine.createContract(playerId, body.type, {
|
|
792
|
+
fromZoneId: body.fromZoneId,
|
|
793
|
+
toZoneId: body.toZoneId,
|
|
794
|
+
resource: body.resource,
|
|
795
|
+
quantity: body.quantity
|
|
796
|
+
}, body.reward, body.deadline, body.bonus && body.bonusDeadline
|
|
797
|
+
? { deadline: body.bonusDeadline, credits: body.bonus }
|
|
798
|
+
: undefined);
|
|
799
|
+
if (!result.success) {
|
|
800
|
+
throw new GameError(ErrorCodes.INSUFFICIENT_CREDITS, result.error || 'Contract creation failed');
|
|
801
|
+
}
|
|
802
|
+
return c.json({ success: true, contract: result.contract });
|
|
803
|
+
});
|
|
804
|
+
// Accept a contract
|
|
805
|
+
app.post('/contracts/:id/accept', authMiddleware, writeRateLimitMiddleware(), async (c) => {
|
|
806
|
+
const playerId = getPlayerId(c);
|
|
807
|
+
const contractId = c.req.param('id');
|
|
808
|
+
const result = await engine.acceptContract(playerId, contractId);
|
|
809
|
+
if (!result.success) {
|
|
810
|
+
throw new GameError(ErrorCodes.CONFLICT, result.error || 'Cannot accept contract');
|
|
811
|
+
}
|
|
812
|
+
return c.json({ success: true });
|
|
813
|
+
});
|
|
814
|
+
// Complete a contract
|
|
815
|
+
app.post('/contracts/:id/complete', authMiddleware, writeRateLimitMiddleware(), async (c) => {
|
|
816
|
+
const playerId = getPlayerId(c);
|
|
817
|
+
const contractId = c.req.param('id');
|
|
818
|
+
const result = await engine.completeContract(playerId, contractId);
|
|
819
|
+
if (!result.success) {
|
|
820
|
+
throw new GameError(ErrorCodes.INVALID_INPUT, result.error || 'Cannot complete contract');
|
|
821
|
+
}
|
|
822
|
+
return c.json({
|
|
823
|
+
success: true,
|
|
824
|
+
reward: result.reward,
|
|
825
|
+
bonus: result.bonus
|
|
826
|
+
});
|
|
827
|
+
});
|
|
828
|
+
// Cancel a contract (poster only)
|
|
829
|
+
app.delete('/contracts/:id', authMiddleware, async (c) => {
|
|
830
|
+
const playerId = getPlayerId(c);
|
|
831
|
+
const contractId = c.req.param('id');
|
|
832
|
+
const result = await engine.cancelContract(playerId, contractId);
|
|
833
|
+
if (!result.success) {
|
|
834
|
+
throw new GameError(ErrorCodes.NOT_YOUR_RESOURCE, result.error || 'Cannot cancel contract');
|
|
835
|
+
}
|
|
836
|
+
return c.json({ success: true });
|
|
837
|
+
});
|
|
838
|
+
// ============================================================================
|
|
839
|
+
// TUTORIAL
|
|
840
|
+
// ============================================================================
|
|
841
|
+
app.get('/tutorial', authMiddleware, async (c) => {
|
|
842
|
+
const playerId = getPlayerId(c);
|
|
843
|
+
const result = await engine.getTutorialStatus(playerId);
|
|
844
|
+
if (!result.success)
|
|
845
|
+
throw new NotFoundError('Player', playerId);
|
|
846
|
+
return c.json(result);
|
|
847
|
+
});
|
|
848
|
+
app.post('/tutorial/complete', authMiddleware, writeRateLimitMiddleware(), async (c) => {
|
|
849
|
+
const playerId = getPlayerId(c);
|
|
850
|
+
const body = await c.req.json();
|
|
851
|
+
const step = body.step;
|
|
852
|
+
if (!step || step < 1 || step > 5) {
|
|
853
|
+
throw new ValidationError('Step must be 1-5');
|
|
854
|
+
}
|
|
855
|
+
const result = await engine.completeTutorialStep(playerId, step);
|
|
856
|
+
if (!result.success) {
|
|
857
|
+
throw new GameError(ErrorCodes.INVALID_STATE, result.error || 'Tutorial step failed');
|
|
858
|
+
}
|
|
859
|
+
return c.json(result);
|
|
860
|
+
});
|
|
861
|
+
// ============================================================================
|
|
862
|
+
// SUBSCRIPTION
|
|
863
|
+
// ============================================================================
|
|
864
|
+
app.post('/subscription/upgrade', authMiddleware, writeRateLimitMiddleware(), async (c) => {
|
|
865
|
+
const playerId = getPlayerId(c);
|
|
866
|
+
const body = await c.req.json();
|
|
867
|
+
const { tier } = body;
|
|
868
|
+
if (!tier || !['operator', 'command'].includes(tier)) {
|
|
869
|
+
throw new ValidationError("Tier must be 'operator' or 'command'");
|
|
870
|
+
}
|
|
871
|
+
// In production, this would verify Stripe payment. For now, just update tier.
|
|
872
|
+
await db.updatePlayer(playerId, { tier: tier });
|
|
873
|
+
return c.json({ success: true, tier });
|
|
874
|
+
});
|
|
875
|
+
app.get('/subscription', authMiddleware, async (c) => {
|
|
876
|
+
const player = getPlayer(c);
|
|
877
|
+
return c.json({
|
|
878
|
+
tier: player.tier,
|
|
879
|
+
limits: TIER_LIMITS[player.tier]
|
|
880
|
+
});
|
|
881
|
+
});
|
|
882
|
+
// ============================================================================
|
|
883
|
+
// DOCTRINES
|
|
884
|
+
// ============================================================================
|
|
885
|
+
app.get('/doctrines', authMiddleware, async (c) => {
|
|
886
|
+
const playerId = getPlayerId(c);
|
|
887
|
+
const result = await engine.getFactionDoctrines(playerId);
|
|
888
|
+
if (!result.success) {
|
|
889
|
+
throw new GameError(ErrorCodes.INVALID_STATE, result.error || 'Failed to get doctrines');
|
|
890
|
+
}
|
|
891
|
+
return c.json(result);
|
|
892
|
+
});
|
|
893
|
+
app.post('/doctrines', authMiddleware, writeRateLimitMiddleware(), async (c) => {
|
|
894
|
+
const playerId = getPlayerId(c);
|
|
895
|
+
const body = await c.req.json();
|
|
896
|
+
const { title, content } = body;
|
|
897
|
+
if (!title || !content) {
|
|
898
|
+
throw new ValidationError('Title and content are required');
|
|
899
|
+
}
|
|
900
|
+
const result = await engine.createDoctrine(playerId, title, content);
|
|
901
|
+
if (!result.success) {
|
|
902
|
+
throw new GameError(ErrorCodes.INVALID_STATE, result.error || 'Failed to create doctrine');
|
|
903
|
+
}
|
|
904
|
+
return c.json(result);
|
|
905
|
+
});
|
|
906
|
+
app.put('/doctrines/:id', authMiddleware, writeRateLimitMiddleware(), async (c) => {
|
|
907
|
+
const playerId = getPlayerId(c);
|
|
908
|
+
const doctrineId = c.req.param('id');
|
|
909
|
+
const body = await c.req.json();
|
|
910
|
+
const { content } = body;
|
|
911
|
+
if (!content) {
|
|
912
|
+
throw new ValidationError('Content is required');
|
|
913
|
+
}
|
|
914
|
+
const result = await engine.updateDoctrine(playerId, doctrineId, content);
|
|
915
|
+
if (!result.success) {
|
|
916
|
+
throw new GameError(ErrorCodes.INVALID_STATE, result.error || 'Failed to update doctrine');
|
|
917
|
+
}
|
|
918
|
+
return c.json(result);
|
|
919
|
+
});
|
|
920
|
+
app.delete('/doctrines/:id', authMiddleware, writeRateLimitMiddleware(), async (c) => {
|
|
921
|
+
const playerId = getPlayerId(c);
|
|
922
|
+
const doctrineId = c.req.param('id');
|
|
923
|
+
const result = await engine.deleteDoctrine(playerId, doctrineId);
|
|
924
|
+
if (!result.success) {
|
|
925
|
+
throw new GameError(ErrorCodes.INVALID_STATE, result.error || 'Failed to delete doctrine');
|
|
926
|
+
}
|
|
927
|
+
return c.json(result);
|
|
928
|
+
});
|
|
929
|
+
// ============================================================================
|
|
930
|
+
// ADVANCED MARKET ORDERS
|
|
931
|
+
// ============================================================================
|
|
932
|
+
app.post('/market/conditional', authMiddleware, writeRateLimitMiddleware(), async (c) => {
|
|
933
|
+
const playerId = getPlayerId(c);
|
|
934
|
+
const body = await c.req.json();
|
|
935
|
+
const { zoneId, resource, side, triggerPrice, quantity, condition } = body;
|
|
936
|
+
if (!zoneId || !resource || !side || !triggerPrice || !quantity || !condition) {
|
|
937
|
+
throw new ValidationError('All fields required: zoneId, resource, side, triggerPrice, quantity, condition');
|
|
938
|
+
}
|
|
939
|
+
const result = await engine.createConditionalOrder(playerId, zoneId, resource, side, triggerPrice, quantity, condition);
|
|
940
|
+
if (!result.success) {
|
|
941
|
+
throw new GameError(ErrorCodes.INVALID_STATE, result.error || 'Failed to create conditional order');
|
|
942
|
+
}
|
|
943
|
+
return c.json(result);
|
|
944
|
+
});
|
|
945
|
+
app.post('/market/time-weighted', authMiddleware, writeRateLimitMiddleware(), async (c) => {
|
|
946
|
+
const playerId = getPlayerId(c);
|
|
947
|
+
const body = await c.req.json();
|
|
948
|
+
const { zoneId, resource, side, price, totalQuantity, quantityPerTick } = body;
|
|
949
|
+
if (!zoneId || !resource || !side || !price || !totalQuantity || !quantityPerTick) {
|
|
950
|
+
throw new ValidationError('All fields required: zoneId, resource, side, price, totalQuantity, quantityPerTick');
|
|
951
|
+
}
|
|
952
|
+
const result = await engine.createTimeWeightedOrder(playerId, zoneId, resource, side, price, totalQuantity, quantityPerTick);
|
|
953
|
+
if (!result.success) {
|
|
954
|
+
throw new GameError(ErrorCodes.INVALID_STATE, result.error || 'Failed to create time-weighted order');
|
|
955
|
+
}
|
|
956
|
+
return c.json(result);
|
|
957
|
+
});
|
|
958
|
+
// ============================================================================
|
|
959
|
+
// WEBHOOKS
|
|
960
|
+
// ============================================================================
|
|
961
|
+
app.get('/webhooks', authMiddleware, async (c) => {
|
|
962
|
+
const playerId = getPlayerId(c);
|
|
963
|
+
const result = await engine.getWebhooks(playerId);
|
|
964
|
+
if (!result.success) {
|
|
965
|
+
throw new GameError(ErrorCodes.INVALID_STATE, result.error || 'Failed to get webhooks');
|
|
966
|
+
}
|
|
967
|
+
return c.json(result);
|
|
968
|
+
});
|
|
969
|
+
app.post('/webhooks', authMiddleware, writeRateLimitMiddleware(), async (c) => {
|
|
970
|
+
const playerId = getPlayerId(c);
|
|
971
|
+
const body = await c.req.json();
|
|
972
|
+
const { url, events } = body;
|
|
973
|
+
if (!url || !events || !Array.isArray(events)) {
|
|
974
|
+
throw new ValidationError('URL and events array are required');
|
|
975
|
+
}
|
|
976
|
+
const result = await engine.registerWebhook(playerId, url, events);
|
|
977
|
+
if (!result.success) {
|
|
978
|
+
throw new GameError(ErrorCodes.INVALID_STATE, result.error || 'Failed to register webhook');
|
|
979
|
+
}
|
|
980
|
+
return c.json(result);
|
|
981
|
+
});
|
|
982
|
+
app.delete('/webhooks/:id', authMiddleware, async (c) => {
|
|
983
|
+
const playerId = getPlayerId(c);
|
|
984
|
+
const webhookId = c.req.param('id');
|
|
985
|
+
const result = await engine.deleteWebhook(playerId, webhookId);
|
|
986
|
+
if (!result.success) {
|
|
987
|
+
throw new GameError(ErrorCodes.INVALID_STATE, result.error || 'Failed to delete webhook');
|
|
988
|
+
}
|
|
989
|
+
return c.json(result);
|
|
990
|
+
});
|
|
991
|
+
// ============================================================================
|
|
992
|
+
// DATA EXPORT
|
|
993
|
+
// ============================================================================
|
|
994
|
+
app.get('/me/export', authMiddleware, async (c) => {
|
|
995
|
+
const playerId = getPlayerId(c);
|
|
996
|
+
const result = await engine.exportPlayerData(playerId);
|
|
997
|
+
if (!result.success) {
|
|
998
|
+
throw new GameError(ErrorCodes.INVALID_STATE, result.error || 'Export failed');
|
|
999
|
+
}
|
|
1000
|
+
return c.json(result);
|
|
1001
|
+
});
|
|
1002
|
+
// ============================================================================
|
|
1003
|
+
// BATCH OPERATIONS
|
|
1004
|
+
// ============================================================================
|
|
1005
|
+
app.post('/batch', authMiddleware, writeRateLimitMiddleware(), async (c) => {
|
|
1006
|
+
const playerId = getPlayerId(c);
|
|
1007
|
+
const body = await c.req.json();
|
|
1008
|
+
const { operations } = body;
|
|
1009
|
+
if (!operations || !Array.isArray(operations)) {
|
|
1010
|
+
throw new ValidationError('Operations array is required');
|
|
1011
|
+
}
|
|
1012
|
+
if (operations.length > 10) {
|
|
1013
|
+
throw new ValidationError('Maximum 10 operations per batch');
|
|
1014
|
+
}
|
|
1015
|
+
const result = await engine.executeBatch(playerId, operations);
|
|
1016
|
+
if (!result.success) {
|
|
1017
|
+
throw new GameError(ErrorCodes.INVALID_STATE, result.error || 'Batch failed');
|
|
1018
|
+
}
|
|
1019
|
+
return c.json(result);
|
|
1020
|
+
});
|
|
1021
|
+
// ============================================================================
|
|
1022
|
+
// FACTION ANALYTICS
|
|
1023
|
+
// ============================================================================
|
|
1024
|
+
app.get('/faction/analytics', authMiddleware, async (c) => {
|
|
1025
|
+
const playerId = getPlayerId(c);
|
|
1026
|
+
const result = await engine.getFactionAnalytics(playerId);
|
|
1027
|
+
if (!result.success) {
|
|
1028
|
+
throw new GameError(ErrorCodes.INVALID_STATE, result.error || 'Analytics failed');
|
|
1029
|
+
}
|
|
1030
|
+
return c.json(result);
|
|
1031
|
+
});
|
|
1032
|
+
app.get('/faction/audit', authMiddleware, async (c) => {
|
|
1033
|
+
const playerId = getPlayerId(c);
|
|
1034
|
+
const limit = parseInt(c.req.query('limit') || '100');
|
|
1035
|
+
const result = await engine.getFactionAuditLogs(playerId, limit);
|
|
1036
|
+
if (!result.success) {
|
|
1037
|
+
throw new GameError(ErrorCodes.INVALID_STATE, result.error || 'Audit log failed');
|
|
1038
|
+
}
|
|
1039
|
+
return c.json(result);
|
|
1040
|
+
});
|
|
1041
|
+
// ============================================================================
|
|
1042
|
+
// ADMIN / TICK SERVER
|
|
1043
|
+
// ============================================================================
|
|
1044
|
+
app.post('/admin/tick', async (c) => {
|
|
1045
|
+
if (!await adminAuth(c))
|
|
1046
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
1047
|
+
const result = await engine.processTick();
|
|
1048
|
+
return c.json({ tick: result.tick, eventCount: result.events.length });
|
|
1049
|
+
});
|
|
1050
|
+
// Admin middleware for all /admin routes below
|
|
1051
|
+
const adminAuth = async (c) => {
|
|
1052
|
+
const adminKey = c.req.header('X-Admin-Key');
|
|
1053
|
+
return adminKey === process.env.ADMIN_KEY;
|
|
1054
|
+
};
|
|
1055
|
+
app.get('/admin/dashboard', async (c) => {
|
|
1056
|
+
if (!await adminAuth(c))
|
|
1057
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
1058
|
+
const [players, zones, factions] = await Promise.all([
|
|
1059
|
+
db.getAllPlayers(),
|
|
1060
|
+
db.getAllZones(),
|
|
1061
|
+
db.getAllFactions()
|
|
1062
|
+
]);
|
|
1063
|
+
const now = Date.now();
|
|
1064
|
+
const currentTick = await db.getCurrentTick();
|
|
1065
|
+
// Active = acted within last 144 ticks (1 day at 10min ticks)
|
|
1066
|
+
const activePlayers = players.filter(p => currentTick - p.lastActionTick < 144);
|
|
1067
|
+
// Recent = acted within last 6 ticks (1 hour)
|
|
1068
|
+
const recentPlayers = players.filter(p => currentTick - p.lastActionTick < 6);
|
|
1069
|
+
const controlledZones = zones.filter(z => z.ownerId);
|
|
1070
|
+
const criticalZones = zones.filter(z => z.supplyLevel < 50 && z.burnRate > 0);
|
|
1071
|
+
const collapsedZones = zones.filter(z => z.supplyLevel === 0 && z.burnRate > 0);
|
|
1072
|
+
const tierCounts = { freelance: 0, operator: 0, command: 0 };
|
|
1073
|
+
for (const p of players) {
|
|
1074
|
+
tierCounts[p.tier]++;
|
|
1075
|
+
}
|
|
1076
|
+
return c.json({
|
|
1077
|
+
overview: {
|
|
1078
|
+
totalPlayers: players.length,
|
|
1079
|
+
activePlayers24h: activePlayers.length,
|
|
1080
|
+
activePlayersLastHour: recentPlayers.length,
|
|
1081
|
+
totalFactions: factions.length,
|
|
1082
|
+
totalZones: zones.length,
|
|
1083
|
+
controlledZones: controlledZones.length,
|
|
1084
|
+
criticalZones: criticalZones.length,
|
|
1085
|
+
collapsedZones: collapsedZones.length,
|
|
1086
|
+
currentTick
|
|
1087
|
+
},
|
|
1088
|
+
tiers: tierCounts,
|
|
1089
|
+
topPlayers: players
|
|
1090
|
+
.sort((a, b) => b.reputation - a.reputation)
|
|
1091
|
+
.slice(0, 20)
|
|
1092
|
+
.map(p => ({
|
|
1093
|
+
id: p.id,
|
|
1094
|
+
name: p.name,
|
|
1095
|
+
tier: p.tier,
|
|
1096
|
+
reputation: p.reputation,
|
|
1097
|
+
credits: p.inventory.credits,
|
|
1098
|
+
lastActionTick: p.lastActionTick,
|
|
1099
|
+
factionId: p.factionId,
|
|
1100
|
+
tutorialStep: p.tutorialStep
|
|
1101
|
+
})),
|
|
1102
|
+
factions: factions.map(f => ({
|
|
1103
|
+
id: f.id,
|
|
1104
|
+
name: f.name,
|
|
1105
|
+
tag: f.tag,
|
|
1106
|
+
memberCount: f.members.length,
|
|
1107
|
+
zoneCount: zones.filter(z => z.ownerId === f.id).length
|
|
1108
|
+
})),
|
|
1109
|
+
zoneHealth: {
|
|
1110
|
+
fortified: zones.filter(z => z.supplyLevel >= 100 && z.complianceStreak >= 50).length,
|
|
1111
|
+
supplied: zones.filter(z => z.supplyLevel >= 100 && z.complianceStreak < 50).length,
|
|
1112
|
+
strained: zones.filter(z => z.supplyLevel >= 50 && z.supplyLevel < 100).length,
|
|
1113
|
+
critical: criticalZones.length,
|
|
1114
|
+
collapsed: collapsedZones.length,
|
|
1115
|
+
neutral: zones.filter(z => !z.ownerId && z.burnRate === 0).length
|
|
1116
|
+
}
|
|
1117
|
+
});
|
|
1118
|
+
});
|
|
1119
|
+
app.get('/admin/players', async (c) => {
|
|
1120
|
+
if (!await adminAuth(c))
|
|
1121
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
1122
|
+
const players = await db.getAllPlayers();
|
|
1123
|
+
const limit = parseInt(c.req.query('limit') || '100');
|
|
1124
|
+
const sort = c.req.query('sort') || 'reputation';
|
|
1125
|
+
const sorted = [...players].sort((a, b) => {
|
|
1126
|
+
switch (sort) {
|
|
1127
|
+
case 'credits': return b.inventory.credits - a.inventory.credits;
|
|
1128
|
+
case 'activity': return b.lastActionTick - a.lastActionTick;
|
|
1129
|
+
case 'name': return a.name.localeCompare(b.name);
|
|
1130
|
+
default: return b.reputation - a.reputation;
|
|
1131
|
+
}
|
|
1132
|
+
});
|
|
1133
|
+
return c.json({
|
|
1134
|
+
total: players.length,
|
|
1135
|
+
players: sorted.slice(0, limit).map(p => ({
|
|
1136
|
+
id: p.id,
|
|
1137
|
+
name: p.name,
|
|
1138
|
+
tier: p.tier,
|
|
1139
|
+
reputation: p.reputation,
|
|
1140
|
+
credits: p.inventory.credits,
|
|
1141
|
+
lastActionTick: p.lastActionTick,
|
|
1142
|
+
locationId: p.locationId,
|
|
1143
|
+
factionId: p.factionId,
|
|
1144
|
+
tutorialStep: p.tutorialStep
|
|
1145
|
+
}))
|
|
1146
|
+
});
|
|
1147
|
+
});
|
|
1148
|
+
app.get('/admin/activity', async (c) => {
|
|
1149
|
+
if (!await adminAuth(c))
|
|
1150
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
1151
|
+
const limit = parseInt(c.req.query('limit') || '200');
|
|
1152
|
+
const type = c.req.query('type') || undefined;
|
|
1153
|
+
const events = await db.getEvents({ limit, type });
|
|
1154
|
+
return c.json({
|
|
1155
|
+
total: events.length,
|
|
1156
|
+
events: events.map(e => ({
|
|
1157
|
+
id: e.id,
|
|
1158
|
+
type: e.type,
|
|
1159
|
+
tick: e.tick,
|
|
1160
|
+
actorId: e.actorId,
|
|
1161
|
+
actorType: e.actorType,
|
|
1162
|
+
data: e.data,
|
|
1163
|
+
timestamp: e.timestamp
|
|
1164
|
+
}))
|
|
1165
|
+
});
|
|
1166
|
+
});
|
|
1167
|
+
app.post('/admin/init-world', async (c) => {
|
|
1168
|
+
if (!await adminAuth(c))
|
|
1169
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
1170
|
+
// Check if world already exists
|
|
1171
|
+
const existingZones = await db.getAllZones();
|
|
1172
|
+
if (existingZones.length > 0) {
|
|
1173
|
+
return c.json({ error: 'World already initialized', zoneCount: existingZones.length }, 400);
|
|
1174
|
+
}
|
|
1175
|
+
const world = generateWorldData();
|
|
1176
|
+
const zoneNameToId = new Map();
|
|
1177
|
+
// Create zones and collect name->id mapping
|
|
1178
|
+
for (const zoneData of world.zones) {
|
|
1179
|
+
const zone = await db.createZone(zoneData);
|
|
1180
|
+
zoneNameToId.set(zone.name, zone.id);
|
|
1181
|
+
}
|
|
1182
|
+
// Map route names to IDs and create routes
|
|
1183
|
+
const routesWithIds = mapRouteNamesToIds(world.routes, zoneNameToId);
|
|
1184
|
+
for (const routeData of routesWithIds) {
|
|
1185
|
+
await db.createRoute(routeData);
|
|
1186
|
+
}
|
|
1187
|
+
return c.json({
|
|
1188
|
+
success: true,
|
|
1189
|
+
zones: world.zones.length,
|
|
1190
|
+
routes: world.routes.length
|
|
1191
|
+
});
|
|
1192
|
+
});
|
|
1193
|
+
// ============================================================================
|
|
1194
|
+
// INITIALIZATION
|
|
1195
|
+
// ============================================================================
|
|
1196
|
+
export async function createApp(tursoUrl, authToken) {
|
|
1197
|
+
db = new TursoDatabase(tursoUrl, authToken);
|
|
1198
|
+
await db.initialize();
|
|
1199
|
+
engine = new AsyncGameEngine(db);
|
|
1200
|
+
return app;
|
|
1201
|
+
}
|
|
1202
|
+
// For direct execution
|
|
1203
|
+
const port = parseInt(process.env.PORT || '3000');
|
|
1204
|
+
if (process.env.NODE_ENV !== 'test') {
|
|
1205
|
+
createApp().then((application) => {
|
|
1206
|
+
const server = serve({
|
|
1207
|
+
fetch: application.fetch,
|
|
1208
|
+
port
|
|
1209
|
+
}, () => {
|
|
1210
|
+
console.log(`
|
|
1211
|
+
╔══════════════════════════════════════════════════════════════╗
|
|
1212
|
+
║ BURNRATE API ║
|
|
1213
|
+
║ The front doesn't feed itself. ║
|
|
1214
|
+
╠══════════════════════════════════════════════════════════════╣
|
|
1215
|
+
║ Server running on port ${port.toString().padEnd(36)}║
|
|
1216
|
+
║ Health check: GET /health ║
|
|
1217
|
+
║ Join game: POST /join { "name": "YourName" } ║
|
|
1218
|
+
╚══════════════════════════════════════════════════════════════╝
|
|
1219
|
+
`);
|
|
1220
|
+
});
|
|
1221
|
+
const shutdown = () => {
|
|
1222
|
+
console.log('\nShutting down API server...');
|
|
1223
|
+
server.close(() => {
|
|
1224
|
+
console.log('API server stopped.');
|
|
1225
|
+
process.exit(0);
|
|
1226
|
+
});
|
|
1227
|
+
// Force exit after 5 seconds if connections don't close
|
|
1228
|
+
setTimeout(() => process.exit(1), 5000);
|
|
1229
|
+
};
|
|
1230
|
+
process.on('SIGINT', shutdown);
|
|
1231
|
+
process.on('SIGTERM', shutdown);
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
export default app;
|