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.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +507 -0
  3. package/dist/cli/format.d.ts +13 -0
  4. package/dist/cli/format.js +319 -0
  5. package/dist/cli/index.d.ts +7 -0
  6. package/dist/cli/index.js +1121 -0
  7. package/dist/cli/setup.d.ts +8 -0
  8. package/dist/cli/setup.js +143 -0
  9. package/dist/core/async-engine.d.ts +411 -0
  10. package/dist/core/async-engine.js +2274 -0
  11. package/dist/core/async-worldgen.d.ts +19 -0
  12. package/dist/core/async-worldgen.js +221 -0
  13. package/dist/core/engine.d.ts +154 -0
  14. package/dist/core/engine.js +1104 -0
  15. package/dist/core/pathfinding.d.ts +38 -0
  16. package/dist/core/pathfinding.js +146 -0
  17. package/dist/core/types.d.ts +489 -0
  18. package/dist/core/types.js +359 -0
  19. package/dist/core/worldgen.d.ts +22 -0
  20. package/dist/core/worldgen.js +292 -0
  21. package/dist/db/database.d.ts +83 -0
  22. package/dist/db/database.js +829 -0
  23. package/dist/db/turso-database.d.ts +177 -0
  24. package/dist/db/turso-database.js +1586 -0
  25. package/dist/mcp/server.d.ts +7 -0
  26. package/dist/mcp/server.js +1877 -0
  27. package/dist/server/api.d.ts +8 -0
  28. package/dist/server/api.js +1234 -0
  29. package/dist/server/async-tick-server.d.ts +5 -0
  30. package/dist/server/async-tick-server.js +63 -0
  31. package/dist/server/errors.d.ts +78 -0
  32. package/dist/server/errors.js +156 -0
  33. package/dist/server/rate-limit.d.ts +22 -0
  34. package/dist/server/rate-limit.js +134 -0
  35. package/dist/server/tick-server.d.ts +9 -0
  36. package/dist/server/tick-server.js +114 -0
  37. package/dist/server/validation.d.ts +194 -0
  38. package/dist/server/validation.js +114 -0
  39. 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;