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,1104 @@
1
+ /**
2
+ * BURNRATE Game Engine
3
+ * Handles tick processing, supply burn, shipment movement, and combat resolution
4
+ */
5
+ import { getSupplyState, SHIPMENT_SPECS, SU_RECIPE, TIER_LIMITS, RECIPES, getFieldResource } from './types.js';
6
+ import { findPath } from './pathfinding.js';
7
+ export class GameEngine {
8
+ db;
9
+ constructor(db) {
10
+ this.db = db;
11
+ }
12
+ // ============================================================================
13
+ // TICK PROCESSING
14
+ // ============================================================================
15
+ /**
16
+ * Process a single game tick
17
+ * This is the core simulation loop
18
+ */
19
+ processTick() {
20
+ const tick = this.db.incrementTick();
21
+ const events = [];
22
+ // Record tick event
23
+ events.push(this.recordEvent('tick', null, 'system', { tick }));
24
+ // 1. Process supply burn for all controlled zones
25
+ const burnEvents = this.processSupplyBurn(tick);
26
+ events.push(...burnEvents);
27
+ // 2. Move shipments
28
+ const shipmentEvents = this.processShipments(tick);
29
+ events.push(...shipmentEvents);
30
+ // 3. Process unit maintenance
31
+ const maintenanceEvents = this.processUnitMaintenance(tick);
32
+ events.push(...maintenanceEvents);
33
+ // 4. Expire old contracts
34
+ const contractEvents = this.processContractExpiration(tick);
35
+ events.push(...contractEvents);
36
+ // 5. Regenerate resources in Fields
37
+ this.processFieldRegeneration(tick);
38
+ // 6. Reset daily action counts (every 144 ticks = 24 hours)
39
+ if (tick % 144 === 0) {
40
+ this.resetDailyActions();
41
+ }
42
+ return { tick, events };
43
+ }
44
+ // ============================================================================
45
+ // SUPPLY BURN
46
+ // ============================================================================
47
+ processSupplyBurn(tick) {
48
+ const events = [];
49
+ const zones = this.db.getAllZones();
50
+ for (const zone of zones) {
51
+ if (zone.burnRate === 0 || !zone.ownerId)
52
+ continue;
53
+ const previousState = getSupplyState(zone.supplyLevel);
54
+ // Calculate SU needed
55
+ const suNeeded = zone.burnRate;
56
+ // Check if we have enough SU
57
+ if (zone.suStockpile >= suNeeded) {
58
+ // Burn the supply
59
+ const newStockpile = zone.suStockpile - suNeeded;
60
+ const newSupplyLevel = Math.min(100, (newStockpile / suNeeded) * 100);
61
+ const newStreak = zone.supplyLevel >= 100 ? zone.complianceStreak + 1 : 0;
62
+ this.db.updateZone(zone.id, {
63
+ suStockpile: newStockpile,
64
+ supplyLevel: newSupplyLevel >= 100 ? 100 : newSupplyLevel,
65
+ complianceStreak: newStreak
66
+ });
67
+ events.push(this.recordEvent('zone_supplied', zone.ownerId, 'faction', {
68
+ zoneId: zone.id,
69
+ zoneName: zone.name,
70
+ suBurned: suNeeded,
71
+ remaining: newStockpile,
72
+ supplyLevel: newSupplyLevel,
73
+ streak: newStreak
74
+ }));
75
+ }
76
+ else {
77
+ // Not enough supply - zone degrades
78
+ const supplied = zone.suStockpile;
79
+ const deficit = suNeeded - supplied;
80
+ const newSupplyLevel = Math.max(0, zone.supplyLevel - (deficit / suNeeded) * 25);
81
+ this.db.updateZone(zone.id, {
82
+ suStockpile: 0,
83
+ supplyLevel: newSupplyLevel,
84
+ complianceStreak: 0
85
+ });
86
+ const newState = getSupplyState(newSupplyLevel);
87
+ if (newState !== previousState) {
88
+ events.push(this.recordEvent('zone_state_changed', zone.ownerId, 'faction', {
89
+ zoneId: zone.id,
90
+ zoneName: zone.name,
91
+ previousState,
92
+ newState,
93
+ supplyLevel: newSupplyLevel
94
+ }));
95
+ // If collapsed, zone becomes neutral
96
+ if (newState === 'collapsed') {
97
+ this.db.updateZone(zone.id, { ownerId: null });
98
+ events.push(this.recordEvent('zone_captured', null, 'system', {
99
+ zoneId: zone.id,
100
+ zoneName: zone.name,
101
+ previousOwner: zone.ownerId,
102
+ newOwner: null,
103
+ reason: 'supply_collapse'
104
+ }));
105
+ }
106
+ }
107
+ }
108
+ }
109
+ return events;
110
+ }
111
+ // ============================================================================
112
+ // SHIPMENT PROCESSING
113
+ // ============================================================================
114
+ processShipments(tick) {
115
+ const events = [];
116
+ const shipments = this.db.getActiveShipments();
117
+ for (const shipment of shipments) {
118
+ // Decrement ticks to next zone
119
+ const newTicks = shipment.ticksToNextZone - 1;
120
+ if (newTicks <= 0) {
121
+ // Move to next zone
122
+ const newPosition = shipment.currentPosition + 1;
123
+ if (newPosition >= shipment.path.length) {
124
+ // Arrived at destination
125
+ this.completeShipment(shipment, tick, events);
126
+ }
127
+ else {
128
+ // Check for interception on this leg
129
+ const fromZoneId = shipment.path[shipment.currentPosition];
130
+ const toZoneId = shipment.path[newPosition];
131
+ const intercepted = this.checkInterception(shipment, fromZoneId, toZoneId, tick);
132
+ if (intercepted) {
133
+ this.interceptShipment(shipment, tick, events);
134
+ }
135
+ else {
136
+ // Continue to next zone
137
+ const route = this.findRoute(fromZoneId, toZoneId);
138
+ const spec = SHIPMENT_SPECS[shipment.type];
139
+ const ticksToNext = route
140
+ ? Math.ceil(route.distance * spec.speedModifier)
141
+ : 1;
142
+ this.db.updateShipment(shipment.id, {
143
+ currentPosition: newPosition,
144
+ ticksToNextZone: ticksToNext
145
+ });
146
+ events.push(this.recordEvent('shipment_moved', shipment.playerId, 'player', {
147
+ shipmentId: shipment.id,
148
+ fromZone: fromZoneId,
149
+ toZone: toZoneId,
150
+ position: newPosition,
151
+ totalLegs: shipment.path.length - 1
152
+ }));
153
+ }
154
+ }
155
+ }
156
+ else {
157
+ // Still in transit
158
+ this.db.updateShipment(shipment.id, { ticksToNextZone: newTicks });
159
+ }
160
+ }
161
+ return events;
162
+ }
163
+ completeShipment(shipment, tick, events) {
164
+ const destinationId = shipment.path[shipment.path.length - 1];
165
+ const player = this.db.getPlayer(shipment.playerId);
166
+ if (player) {
167
+ // Add cargo to player inventory
168
+ const newInventory = { ...player.inventory };
169
+ for (const [resource, amount] of Object.entries(shipment.cargo)) {
170
+ if (amount && resource in newInventory) {
171
+ newInventory[resource] += amount;
172
+ }
173
+ }
174
+ this.db.updatePlayer(player.id, { inventory: newInventory, locationId: destinationId });
175
+ }
176
+ this.db.updateShipment(shipment.id, { status: 'arrived' });
177
+ events.push(this.recordEvent('shipment_arrived', shipment.playerId, 'player', {
178
+ shipmentId: shipment.id,
179
+ destination: destinationId,
180
+ cargo: shipment.cargo
181
+ }));
182
+ }
183
+ checkInterception(shipment, fromZoneId, toZoneId, tick) {
184
+ const route = this.findRoute(fromZoneId, toZoneId);
185
+ if (!route)
186
+ return false;
187
+ const spec = SHIPMENT_SPECS[shipment.type];
188
+ let interceptionChance = route.baseRisk * route.chokepointRating * spec.visibilityModifier;
189
+ // Check for raiders deployed on this route
190
+ const raiders = this.getRaidersOnRoute(route.id);
191
+ const totalRaiderStrength = raiders.reduce((sum, r) => sum + r.strength, 0);
192
+ // Raiders significantly increase interception chance
193
+ if (totalRaiderStrength > 0) {
194
+ interceptionChance += totalRaiderStrength * 0.05; // Each strength point adds 5%
195
+ }
196
+ // Reduce by escort strength
197
+ const totalEscortStrength = shipment.escortIds.reduce((sum, id) => {
198
+ const unit = this.db.getUnit(id);
199
+ return sum + (unit?.strength || 0);
200
+ }, 0);
201
+ // Escorts counter raiders: if escort >= raider strength, major reduction
202
+ if (totalEscortStrength >= totalRaiderStrength) {
203
+ interceptionChance *= Math.max(0.1, 1 - totalEscortStrength * 0.1);
204
+ }
205
+ else {
206
+ // Raiders have the advantage
207
+ interceptionChance *= Math.max(0.2, 1 - (totalEscortStrength - totalRaiderStrength) * 0.05);
208
+ }
209
+ // Cap at 95% max
210
+ interceptionChance = Math.min(0.95, interceptionChance);
211
+ // Random roll
212
+ return Math.random() < interceptionChance;
213
+ }
214
+ /**
215
+ * Get all raiders deployed on a specific route
216
+ */
217
+ getRaidersOnRoute(routeId) {
218
+ // Get all units and filter for raiders assigned to this route
219
+ const allPlayers = this.db.getAllPlayers();
220
+ const raiders = [];
221
+ for (const player of allPlayers) {
222
+ const units = this.db.getPlayerUnits(player.id);
223
+ for (const unit of units) {
224
+ if (unit.type === 'raider' && unit.assignmentId === routeId) {
225
+ raiders.push(unit);
226
+ }
227
+ }
228
+ }
229
+ return raiders;
230
+ }
231
+ interceptShipment(shipment, tick, events) {
232
+ const fromZoneId = shipment.path[shipment.currentPosition];
233
+ const toZoneId = shipment.path[shipment.currentPosition + 1];
234
+ const route = this.findRoute(fromZoneId, toZoneId);
235
+ // Get combatants
236
+ const escorts = shipment.escortIds.map(id => this.db.getUnit(id)).filter(u => u);
237
+ const raiders = route ? this.getRaidersOnRoute(route.id) : [];
238
+ const totalEscortStrength = escorts.reduce((sum, u) => sum + u.strength, 0);
239
+ const totalRaiderStrength = raiders.reduce((sum, u) => sum + u.strength, 0);
240
+ // Combat resolution
241
+ let cargoLossPct = 0.5; // Default 50% loss
242
+ const combatEvents = [];
243
+ if (totalRaiderStrength > 0 && totalEscortStrength > 0) {
244
+ // Combat between escorts and raiders
245
+ const escortAdvantage = totalEscortStrength - totalRaiderStrength;
246
+ if (escortAdvantage > 10) {
247
+ // Escorts win decisively - minimal cargo loss
248
+ cargoLossPct = 0.1;
249
+ // Destroy some raiders
250
+ for (const raider of raiders.slice(0, Math.floor(raiders.length / 2))) {
251
+ this.db.deleteUnit(raider.id);
252
+ combatEvents.push({ type: 'raider_destroyed', unitId: raider.id });
253
+ }
254
+ }
255
+ else if (escortAdvantage > 0) {
256
+ // Escorts win narrowly
257
+ cargoLossPct = 0.25;
258
+ }
259
+ else if (escortAdvantage > -10) {
260
+ // Raiders win narrowly
261
+ cargoLossPct = 0.5;
262
+ // Damage some escorts
263
+ for (const escort of escorts.slice(0, 1)) {
264
+ this.db.deleteUnit(escort.id);
265
+ combatEvents.push({ type: 'escort_destroyed', unitId: escort.id });
266
+ }
267
+ }
268
+ else {
269
+ // Raiders win decisively
270
+ cargoLossPct = 0.75;
271
+ // Destroy most escorts
272
+ for (const escort of escorts.slice(0, Math.ceil(escorts.length * 0.75))) {
273
+ this.db.deleteUnit(escort.id);
274
+ combatEvents.push({ type: 'escort_destroyed', unitId: escort.id });
275
+ }
276
+ }
277
+ events.push(this.recordEvent('combat_resolved', null, 'system', {
278
+ location: fromZoneId,
279
+ escortStrength: totalEscortStrength,
280
+ raiderStrength: totalRaiderStrength,
281
+ outcome: escortAdvantage > 0 ? 'escort_victory' : 'raider_victory',
282
+ casualties: combatEvents
283
+ }));
284
+ }
285
+ // Calculate cargo loss
286
+ const lostCargo = {};
287
+ const remainingCargo = {};
288
+ for (const [resource, amount] of Object.entries(shipment.cargo)) {
289
+ if (amount) {
290
+ const lost = Math.floor(amount * cargoLossPct);
291
+ lostCargo[resource] = lost;
292
+ remainingCargo[resource] = amount - lost;
293
+ }
294
+ }
295
+ // If any cargo remains and escorts survived, shipment continues (damaged)
296
+ // Otherwise, shipment is fully intercepted
297
+ const survivingEscorts = shipment.escortIds.filter(id => {
298
+ const unit = this.db.getUnit(id);
299
+ return unit !== null;
300
+ });
301
+ if (cargoLossPct < 1 && Object.values(remainingCargo).some(v => v && v > 0)) {
302
+ // Partial interception - cargo reduced but shipment continues
303
+ this.db.updateShipment(shipment.id, {
304
+ escortIds: survivingEscorts
305
+ });
306
+ // Note: We'd need to update cargo too, but current schema doesn't support that easily
307
+ // For now, just record the event
308
+ events.push(this.recordEvent('shipment_intercepted', shipment.playerId, 'player', {
309
+ shipmentId: shipment.id,
310
+ lostCargo,
311
+ remainingCargo,
312
+ location: fromZoneId,
313
+ outcome: 'partial_loss',
314
+ cargoLossPct: Math.round(cargoLossPct * 100)
315
+ }));
316
+ }
317
+ else {
318
+ // Full interception
319
+ this.db.updateShipment(shipment.id, { status: 'intercepted' });
320
+ events.push(this.recordEvent('shipment_intercepted', shipment.playerId, 'player', {
321
+ shipmentId: shipment.id,
322
+ lostCargo,
323
+ location: fromZoneId,
324
+ outcome: 'total_loss'
325
+ }));
326
+ }
327
+ }
328
+ // ============================================================================
329
+ // UNIT MAINTENANCE
330
+ // ============================================================================
331
+ processUnitMaintenance(tick) {
332
+ const events = [];
333
+ const players = this.db.getAllPlayers();
334
+ for (const player of players) {
335
+ const units = this.db.getPlayerUnits(player.id);
336
+ let totalMaintenance = 0;
337
+ for (const unit of units) {
338
+ totalMaintenance += unit.maintenance;
339
+ }
340
+ if (totalMaintenance > 0 && player.inventory.credits >= totalMaintenance) {
341
+ const newInventory = { ...player.inventory };
342
+ newInventory.credits -= totalMaintenance;
343
+ this.db.updatePlayer(player.id, { inventory: newInventory });
344
+ }
345
+ else if (totalMaintenance > player.inventory.credits) {
346
+ // Can't afford maintenance - units desert (delete one)
347
+ if (units.length > 0) {
348
+ const unitToDelete = units[0];
349
+ this.db.deleteUnit(unitToDelete.id);
350
+ events.push(this.recordEvent('player_action', player.id, 'player', {
351
+ action: 'unit_deserted',
352
+ unitId: unitToDelete.id,
353
+ reason: 'maintenance_unpaid'
354
+ }));
355
+ }
356
+ }
357
+ }
358
+ return events;
359
+ }
360
+ // ============================================================================
361
+ // CONTRACT EXPIRATION
362
+ // ============================================================================
363
+ processContractExpiration(tick) {
364
+ const events = [];
365
+ const contracts = this.db.getOpenContracts();
366
+ for (const contract of contracts) {
367
+ if (contract.deadline < tick && contract.status === 'open') {
368
+ this.db.updateContract(contract.id, { status: 'expired' });
369
+ }
370
+ else if (contract.deadline < tick && contract.status === 'active') {
371
+ this.db.updateContract(contract.id, { status: 'failed' });
372
+ events.push(this.recordEvent('contract_failed', contract.acceptedBy, 'player', {
373
+ contractId: contract.id,
374
+ reason: 'deadline_missed'
375
+ }));
376
+ }
377
+ }
378
+ return events;
379
+ }
380
+ // ============================================================================
381
+ // DAILY RESET
382
+ // ============================================================================
383
+ resetDailyActions() {
384
+ const players = this.db.getAllPlayers();
385
+ for (const player of players) {
386
+ this.db.updatePlayer(player.id, { actionsToday: 0 });
387
+ }
388
+ }
389
+ // ============================================================================
390
+ // FIELD REGENERATION
391
+ // ============================================================================
392
+ processFieldRegeneration(tick) {
393
+ const zones = this.db.getAllZones();
394
+ for (const zone of zones) {
395
+ if (zone.type !== 'field')
396
+ continue;
397
+ // Determine what resource this field produces
398
+ const resource = getFieldResource(zone.name);
399
+ if (!resource)
400
+ continue;
401
+ // Regeneration rate: productionCapacity per tick, up to a max stockpile
402
+ const maxStockpile = 1000; // Max resources a field can hold
403
+ const regenAmount = Math.floor(zone.productionCapacity / 10); // Regen 10% of capacity per tick
404
+ const currentAmount = zone.inventory[resource] || 0;
405
+ if (currentAmount < maxStockpile) {
406
+ const newAmount = Math.min(maxStockpile, currentAmount + regenAmount);
407
+ const newInventory = { ...zone.inventory };
408
+ newInventory[resource] = newAmount;
409
+ this.db.updateZone(zone.id, { inventory: newInventory });
410
+ }
411
+ }
412
+ }
413
+ // ============================================================================
414
+ // PLAYER ACTIONS
415
+ // ============================================================================
416
+ /**
417
+ * Check if player can perform an action (rate limiting)
418
+ */
419
+ canPlayerAct(playerId) {
420
+ const player = this.db.getPlayer(playerId);
421
+ if (!player)
422
+ return { allowed: false, reason: 'Player not found' };
423
+ const tick = this.db.getCurrentTick();
424
+ const limits = TIER_LIMITS[player.tier];
425
+ // Check daily quota
426
+ if (player.actionsToday >= limits.dailyActions) {
427
+ return { allowed: false, reason: `Daily action limit (${limits.dailyActions}) reached` };
428
+ }
429
+ // Check rate limit (1 action per 30 seconds = 0.05 ticks at 10min/tick)
430
+ // For simplicity, allow 1 action per tick
431
+ if (player.lastActionTick >= tick) {
432
+ return { allowed: false, reason: 'Rate limited. Wait for next tick.' };
433
+ }
434
+ return { allowed: true };
435
+ }
436
+ /**
437
+ * Record that player took an action
438
+ */
439
+ recordPlayerAction(playerId) {
440
+ const player = this.db.getPlayer(playerId);
441
+ if (!player)
442
+ return;
443
+ const tick = this.db.getCurrentTick();
444
+ this.db.updatePlayer(playerId, {
445
+ actionsToday: player.actionsToday + 1,
446
+ lastActionTick: tick
447
+ });
448
+ }
449
+ /**
450
+ * Create a new shipment with an explicit path
451
+ * Players must specify waypoints - no automatic pathfinding
452
+ */
453
+ createShipmentWithPath(playerId, type, path, cargo) {
454
+ const canAct = this.canPlayerAct(playerId);
455
+ if (!canAct.allowed)
456
+ return { success: false, error: canAct.reason };
457
+ const player = this.db.getPlayer(playerId);
458
+ if (!player)
459
+ return { success: false, error: 'Player not found' };
460
+ if (path.length < 2) {
461
+ return { success: false, error: 'Path must have at least origin and destination' };
462
+ }
463
+ const fromZoneId = path[0];
464
+ const toZoneId = path[path.length - 1];
465
+ // Check license
466
+ if (!player.licenses[type]) {
467
+ return { success: false, error: `No ${type} license` };
468
+ }
469
+ // Check player is at origin
470
+ if (player.locationId !== fromZoneId) {
471
+ return { success: false, error: 'Must be at origin zone' };
472
+ }
473
+ // Check cargo capacity
474
+ const spec = SHIPMENT_SPECS[type];
475
+ const totalCargo = Object.values(cargo).reduce((sum, v) => sum + (v || 0), 0);
476
+ if (totalCargo > spec.capacity) {
477
+ return { success: false, error: `Cargo (${totalCargo}) exceeds ${type} capacity (${spec.capacity})` };
478
+ }
479
+ // Check player has cargo
480
+ for (const [resource, amount] of Object.entries(cargo)) {
481
+ if (amount && player.inventory[resource] < amount) {
482
+ return { success: false, error: `Insufficient ${resource}` };
483
+ }
484
+ }
485
+ // Validate all path legs have direct routes
486
+ for (let i = 0; i < path.length - 1; i++) {
487
+ const routes = this.db.getRoutesBetween(path[i], path[i + 1]);
488
+ if (routes.length === 0) {
489
+ return { success: false, error: `No direct route for leg ${i + 1}` };
490
+ }
491
+ }
492
+ // Calculate ticks to first waypoint
493
+ const firstRoute = this.db.getRoutesBetween(path[0], path[1])[0];
494
+ const ticksToNext = Math.ceil(firstRoute.distance * spec.speedModifier);
495
+ // Deduct cargo from player
496
+ const newInventory = { ...player.inventory };
497
+ for (const [resource, amount] of Object.entries(cargo)) {
498
+ if (amount) {
499
+ newInventory[resource] -= amount;
500
+ }
501
+ }
502
+ this.db.updatePlayer(playerId, { inventory: newInventory });
503
+ // Create shipment
504
+ const tick = this.db.getCurrentTick();
505
+ const shipment = this.db.createShipment({
506
+ playerId,
507
+ type,
508
+ path,
509
+ currentPosition: 0,
510
+ ticksToNextZone: ticksToNext,
511
+ cargo,
512
+ escortIds: [],
513
+ createdAt: tick,
514
+ status: 'in_transit'
515
+ });
516
+ this.recordPlayerAction(playerId);
517
+ this.recordEvent('shipment_created', playerId, 'player', {
518
+ shipmentId: shipment.id,
519
+ type,
520
+ path,
521
+ from: fromZoneId,
522
+ to: toZoneId,
523
+ cargo
524
+ });
525
+ return { success: true, shipment };
526
+ }
527
+ /**
528
+ * Create a new shipment (legacy - uses pathfinding internally)
529
+ * @deprecated Use createShipmentWithPath for explicit control
530
+ */
531
+ createShipment(playerId, type, fromZoneId, toZoneId, cargo) {
532
+ // For backwards compatibility, find path automatically
533
+ // But this is deprecated - players should use explicit paths
534
+ const pathResult = findPath(this.db, fromZoneId, toZoneId);
535
+ if (!pathResult) {
536
+ return { success: false, error: 'No route between zones' };
537
+ }
538
+ return this.createShipmentWithPath(playerId, type, pathResult.path, cargo);
539
+ }
540
+ /**
541
+ * Place a market order
542
+ */
543
+ placeOrder(playerId, zoneId, resource, side, price, quantity) {
544
+ const canAct = this.canPlayerAct(playerId);
545
+ if (!canAct.allowed)
546
+ return { success: false, error: canAct.reason };
547
+ const player = this.db.getPlayer(playerId);
548
+ if (!player)
549
+ return { success: false, error: 'Player not found' };
550
+ // Check player is at zone
551
+ if (player.locationId !== zoneId) {
552
+ return { success: false, error: 'Must be at zone to trade' };
553
+ }
554
+ // Check order limits
555
+ const limits = TIER_LIMITS[player.tier];
556
+ const existingOrders = this.db.getOrdersForZone(zoneId).filter(o => o.playerId === playerId);
557
+ if (existingOrders.length >= limits.marketOrders) {
558
+ return { success: false, error: `Order limit (${limits.marketOrders}) reached` };
559
+ }
560
+ if (side === 'sell') {
561
+ // Check player has the resource
562
+ if (player.inventory[resource] < quantity) {
563
+ return { success: false, error: `Insufficient ${resource}` };
564
+ }
565
+ // Reserve the resource
566
+ const newInventory = { ...player.inventory };
567
+ newInventory[resource] -= quantity;
568
+ this.db.updatePlayer(playerId, { inventory: newInventory });
569
+ }
570
+ else {
571
+ // Check player has credits
572
+ const totalCost = price * quantity;
573
+ if (player.inventory.credits < totalCost) {
574
+ return { success: false, error: 'Insufficient credits' };
575
+ }
576
+ // Reserve credits
577
+ const newInventory = { ...player.inventory };
578
+ newInventory.credits -= totalCost;
579
+ this.db.updatePlayer(playerId, { inventory: newInventory });
580
+ }
581
+ const tick = this.db.getCurrentTick();
582
+ const order = this.db.createOrder({
583
+ playerId,
584
+ zoneId,
585
+ resource,
586
+ side,
587
+ price,
588
+ quantity,
589
+ originalQuantity: quantity,
590
+ createdAt: tick
591
+ });
592
+ this.recordPlayerAction(playerId);
593
+ // Try to match orders
594
+ this.matchOrders(zoneId, resource);
595
+ this.recordEvent('order_placed', playerId, 'player', {
596
+ orderId: order.id,
597
+ resource,
598
+ side,
599
+ price,
600
+ quantity
601
+ });
602
+ return { success: true, order };
603
+ }
604
+ /**
605
+ * Match buy and sell orders
606
+ */
607
+ matchOrders(zoneId, resource) {
608
+ const orders = this.db.getOrdersForZone(zoneId, resource);
609
+ const buys = orders.filter(o => o.side === 'buy').sort((a, b) => b.price - a.price);
610
+ const sells = orders.filter(o => o.side === 'sell').sort((a, b) => a.price - b.price);
611
+ for (const buy of buys) {
612
+ for (const sell of sells) {
613
+ if (buy.price >= sell.price && buy.quantity > 0 && sell.quantity > 0) {
614
+ const tradeQty = Math.min(buy.quantity, sell.quantity);
615
+ const tradePrice = sell.price; // Price-time priority: seller's price
616
+ // Execute trade
617
+ this.executeTrade(buy, sell, tradeQty, tradePrice);
618
+ }
619
+ }
620
+ }
621
+ }
622
+ executeTrade(buyOrder, sellOrder, quantity, price) {
623
+ const tick = this.db.getCurrentTick();
624
+ // Update order quantities
625
+ this.db.updateOrder(buyOrder.id, buyOrder.quantity - quantity);
626
+ this.db.updateOrder(sellOrder.id, sellOrder.quantity - quantity);
627
+ // Transfer resource to buyer
628
+ const buyer = this.db.getPlayer(buyOrder.playerId);
629
+ if (buyer) {
630
+ const newInventory = { ...buyer.inventory };
631
+ newInventory[buyOrder.resource] += quantity;
632
+ // Refund excess credits if price was lower than bid
633
+ const refund = (buyOrder.price - price) * quantity;
634
+ newInventory.credits += refund;
635
+ this.db.updatePlayer(buyer.id, { inventory: newInventory });
636
+ }
637
+ // Transfer credits to seller
638
+ const seller = this.db.getPlayer(sellOrder.playerId);
639
+ if (seller) {
640
+ const newInventory = { ...seller.inventory };
641
+ newInventory.credits += price * quantity;
642
+ this.db.updatePlayer(seller.id, { inventory: newInventory });
643
+ }
644
+ this.recordEvent('trade_executed', null, 'system', {
645
+ zoneId: buyOrder.zoneId,
646
+ resource: buyOrder.resource,
647
+ buyerId: buyOrder.playerId,
648
+ sellerId: sellOrder.playerId,
649
+ price,
650
+ quantity
651
+ });
652
+ }
653
+ /**
654
+ * Deposit SU to a zone
655
+ */
656
+ depositSU(playerId, zoneId, amount) {
657
+ const canAct = this.canPlayerAct(playerId);
658
+ if (!canAct.allowed)
659
+ return { success: false, error: canAct.reason };
660
+ const player = this.db.getPlayer(playerId);
661
+ if (!player)
662
+ return { success: false, error: 'Player not found' };
663
+ const zone = this.db.getZone(zoneId);
664
+ if (!zone)
665
+ return { success: false, error: 'Zone not found' };
666
+ if (player.locationId !== zoneId) {
667
+ return { success: false, error: 'Must be at zone' };
668
+ }
669
+ // Check player has SU components
670
+ const needed = {
671
+ rations: SU_RECIPE.rations * amount,
672
+ fuel: SU_RECIPE.fuel * amount,
673
+ parts: SU_RECIPE.parts * amount,
674
+ ammo: SU_RECIPE.ammo * amount
675
+ };
676
+ for (const [resource, qty] of Object.entries(needed)) {
677
+ if (player.inventory[resource] < qty) {
678
+ return { success: false, error: `Insufficient ${resource} for ${amount} SU` };
679
+ }
680
+ }
681
+ // Deduct from player
682
+ const newInventory = { ...player.inventory };
683
+ newInventory.rations -= needed.rations;
684
+ newInventory.fuel -= needed.fuel;
685
+ newInventory.parts -= needed.parts;
686
+ newInventory.ammo -= needed.ammo;
687
+ this.db.updatePlayer(playerId, { inventory: newInventory });
688
+ // Add to zone
689
+ this.db.updateZone(zoneId, { suStockpile: zone.suStockpile + amount });
690
+ this.recordPlayerAction(playerId);
691
+ this.recordEvent('zone_supplied', playerId, 'player', {
692
+ zoneId,
693
+ amount,
694
+ newStockpile: zone.suStockpile + amount
695
+ });
696
+ return { success: true };
697
+ }
698
+ /**
699
+ * Scan a zone or route for intel
700
+ * If player is in a faction, intel is automatically shared with faction members
701
+ */
702
+ scan(playerId, targetType, targetId) {
703
+ const canAct = this.canPlayerAct(playerId);
704
+ if (!canAct.allowed)
705
+ return { success: false, error: canAct.reason };
706
+ const player = this.db.getPlayer(playerId);
707
+ if (!player)
708
+ return { success: false, error: 'Player not found' };
709
+ const tick = this.db.getCurrentTick();
710
+ let data = {};
711
+ if (targetType === 'zone') {
712
+ const zone = this.db.getZone(targetId);
713
+ if (!zone)
714
+ return { success: false, error: 'Zone not found' };
715
+ // Get owner faction name if owned
716
+ let ownerName = null;
717
+ if (zone.ownerId) {
718
+ const ownerFaction = this.db.getFaction(zone.ownerId);
719
+ ownerName = ownerFaction ? `${ownerFaction.name} [${ownerFaction.tag}]` : zone.ownerId;
720
+ }
721
+ data = {
722
+ name: zone.name,
723
+ type: zone.type,
724
+ owner: zone.ownerId,
725
+ ownerName,
726
+ supplyState: getSupplyState(zone.supplyLevel),
727
+ supplyLevel: zone.supplyLevel,
728
+ suStockpile: zone.suStockpile,
729
+ burnRate: zone.burnRate,
730
+ marketActivity: this.db.getOrdersForZone(targetId).length,
731
+ garrisonLevel: zone.garrisonLevel
732
+ };
733
+ }
734
+ else {
735
+ const route = this.db.getRoute(targetId);
736
+ if (!route)
737
+ return { success: false, error: 'Route not found' };
738
+ // Count shipments on this route
739
+ const shipments = this.db.getActiveShipments().filter(s => s.path.includes(route.fromZoneId) && s.path.includes(route.toZoneId));
740
+ // Count raiders on this route
741
+ const raiders = this.getRaidersOnRoute(route.id);
742
+ data = {
743
+ from: route.fromZoneId,
744
+ to: route.toZoneId,
745
+ distance: route.distance,
746
+ baseRisk: route.baseRisk,
747
+ chokepointRating: route.chokepointRating,
748
+ activeShipments: shipments.length,
749
+ raiderPresence: raiders.length > 0,
750
+ raiderStrength: raiders.reduce((sum, r) => sum + r.strength, 0)
751
+ };
752
+ }
753
+ // Share with faction if player is in one
754
+ const intel = this.db.createIntel({
755
+ playerId,
756
+ factionId: player.factionId, // Auto-share with faction
757
+ targetType,
758
+ targetId,
759
+ gatheredAt: tick,
760
+ data,
761
+ signalQuality: 100 // Fresh intel
762
+ });
763
+ this.recordPlayerAction(playerId);
764
+ this.recordEvent('intel_gathered', playerId, 'player', {
765
+ targetType,
766
+ targetId,
767
+ signalQuality: 100,
768
+ sharedWithFaction: !!player.factionId
769
+ });
770
+ return { success: true, intel: data };
771
+ }
772
+ /**
773
+ * Produce resources or units at a Factory
774
+ */
775
+ produce(playerId, output, quantity) {
776
+ const canAct = this.canPlayerAct(playerId);
777
+ if (!canAct.allowed)
778
+ return { success: false, error: canAct.reason };
779
+ const player = this.db.getPlayer(playerId);
780
+ if (!player)
781
+ return { success: false, error: 'Player not found' };
782
+ const zone = this.db.getZone(player.locationId);
783
+ if (!zone)
784
+ return { success: false, error: 'Zone not found' };
785
+ if (zone.type !== 'factory') {
786
+ return { success: false, error: 'Can only produce at Factories' };
787
+ }
788
+ const recipe = RECIPES[output];
789
+ if (!recipe) {
790
+ return { success: false, error: `Unknown product: ${output}. Valid: metal, chemicals, rations, textiles, ammo, medkits, parts, comms, escort, raider` };
791
+ }
792
+ // Check player has all inputs
793
+ for (const [resource, needed] of Object.entries(recipe.inputs)) {
794
+ const totalNeeded = (needed || 0) * quantity;
795
+ if (player.inventory[resource] < totalNeeded) {
796
+ return { success: false, error: `Insufficient ${resource}. Need ${totalNeeded}, have ${player.inventory[resource]}` };
797
+ }
798
+ }
799
+ // Deduct inputs
800
+ const newInventory = { ...player.inventory };
801
+ for (const [resource, needed] of Object.entries(recipe.inputs)) {
802
+ newInventory[resource] -= (needed || 0) * quantity;
803
+ }
804
+ // Handle unit production differently
805
+ if (recipe.isUnit) {
806
+ this.db.updatePlayer(playerId, { inventory: newInventory });
807
+ const units = [];
808
+ const unitType = output;
809
+ for (let i = 0; i < quantity; i++) {
810
+ const unit = this.db.createUnit({
811
+ playerId,
812
+ type: unitType,
813
+ locationId: player.locationId,
814
+ strength: unitType === 'escort' ? 10 : 15,
815
+ speed: unitType === 'escort' ? 1 : 2,
816
+ maintenance: unitType === 'escort' ? 5 : 8,
817
+ assignmentId: null,
818
+ forSalePrice: null
819
+ });
820
+ units.push(unit);
821
+ }
822
+ this.recordPlayerAction(playerId);
823
+ this.recordEvent('player_action', playerId, 'player', {
824
+ action: 'produce_unit',
825
+ unitType,
826
+ quantity,
827
+ zone: zone.name
828
+ });
829
+ return { success: true, produced: quantity, units };
830
+ }
831
+ // Regular resource production
832
+ newInventory[output] += quantity;
833
+ this.db.updatePlayer(playerId, { inventory: newInventory });
834
+ this.recordPlayerAction(playerId);
835
+ this.recordEvent('player_action', playerId, 'player', {
836
+ action: 'produce',
837
+ output,
838
+ quantity,
839
+ zone: zone.name
840
+ });
841
+ return { success: true, produced: quantity };
842
+ }
843
+ /**
844
+ * Extract raw resources from a Field
845
+ */
846
+ extract(playerId, quantity) {
847
+ const canAct = this.canPlayerAct(playerId);
848
+ if (!canAct.allowed)
849
+ return { success: false, error: canAct.reason };
850
+ const player = this.db.getPlayer(playerId);
851
+ if (!player)
852
+ return { success: false, error: 'Player not found' };
853
+ const zone = this.db.getZone(player.locationId);
854
+ if (!zone)
855
+ return { success: false, error: 'Zone not found' };
856
+ if (zone.type !== 'field') {
857
+ return { success: false, error: 'Can only extract at Fields' };
858
+ }
859
+ // Determine what resource this field produces
860
+ const resource = getFieldResource(zone.name);
861
+ if (!resource) {
862
+ return { success: false, error: 'This field has no extractable resources' };
863
+ }
864
+ // Check zone has the resource
865
+ if (zone.inventory[resource] < quantity) {
866
+ return { success: false, error: `Insufficient ${resource} in field. Available: ${zone.inventory[resource]}` };
867
+ }
868
+ // Extraction cost (credits per unit)
869
+ const extractionCost = 5 * quantity;
870
+ if (player.inventory.credits < extractionCost) {
871
+ return { success: false, error: `Insufficient credits. Extraction costs ${extractionCost} cr` };
872
+ }
873
+ // Deduct from zone, add to player
874
+ const newZoneInventory = { ...zone.inventory };
875
+ newZoneInventory[resource] -= quantity;
876
+ this.db.updateZone(zone.id, { inventory: newZoneInventory });
877
+ const newPlayerInventory = { ...player.inventory };
878
+ newPlayerInventory[resource] += quantity;
879
+ newPlayerInventory.credits -= extractionCost;
880
+ this.db.updatePlayer(playerId, { inventory: newPlayerInventory });
881
+ this.recordPlayerAction(playerId);
882
+ this.recordEvent('player_action', playerId, 'player', {
883
+ action: 'extract',
884
+ resource,
885
+ quantity,
886
+ zone: zone.name
887
+ });
888
+ return { success: true, extracted: { resource, amount: quantity } };
889
+ }
890
+ /**
891
+ * Capture a neutral zone for a faction
892
+ */
893
+ captureZone(playerId, zoneId) {
894
+ const canAct = this.canPlayerAct(playerId);
895
+ if (!canAct.allowed)
896
+ return { success: false, error: canAct.reason };
897
+ const player = this.db.getPlayer(playerId);
898
+ if (!player)
899
+ return { success: false, error: 'Player not found' };
900
+ if (!player.factionId) {
901
+ return { success: false, error: 'Must be in a faction to capture zones' };
902
+ }
903
+ const zone = this.db.getZone(zoneId);
904
+ if (!zone)
905
+ return { success: false, error: 'Zone not found' };
906
+ if (player.locationId !== zoneId) {
907
+ return { success: false, error: 'Must be at the zone to capture it' };
908
+ }
909
+ if (zone.ownerId) {
910
+ if (zone.ownerId === player.factionId) {
911
+ return { success: false, error: 'Zone already controlled by your faction' };
912
+ }
913
+ // Can only capture if zone is collapsed
914
+ if (zone.supplyLevel > 0) {
915
+ return { success: false, error: 'Zone is defended. Supply must collapse before capture.' };
916
+ }
917
+ }
918
+ // Capture the zone
919
+ this.db.updateZone(zoneId, {
920
+ ownerId: player.factionId,
921
+ supplyLevel: 0,
922
+ complianceStreak: 0
923
+ });
924
+ this.recordPlayerAction(playerId);
925
+ this.recordEvent('zone_captured', player.factionId, 'faction', {
926
+ zoneId,
927
+ zoneName: zone.name,
928
+ previousOwner: zone.ownerId,
929
+ newOwner: player.factionId,
930
+ capturedBy: playerId
931
+ });
932
+ return { success: true };
933
+ }
934
+ /**
935
+ * Assign an escort unit to a shipment
936
+ */
937
+ assignEscort(playerId, unitId, shipmentId) {
938
+ const player = this.db.getPlayer(playerId);
939
+ if (!player)
940
+ return { success: false, error: 'Player not found' };
941
+ const unit = this.db.getUnit(unitId);
942
+ if (!unit)
943
+ return { success: false, error: 'Unit not found' };
944
+ if (unit.playerId !== playerId) {
945
+ return { success: false, error: 'Not your unit' };
946
+ }
947
+ if (unit.type !== 'escort') {
948
+ return { success: false, error: 'Only escort units can be assigned to shipments' };
949
+ }
950
+ const shipment = this.db.getShipment(shipmentId);
951
+ if (!shipment)
952
+ return { success: false, error: 'Shipment not found' };
953
+ if (shipment.playerId !== playerId) {
954
+ return { success: false, error: 'Not your shipment' };
955
+ }
956
+ if (shipment.status !== 'in_transit') {
957
+ return { success: false, error: 'Shipment not in transit' };
958
+ }
959
+ // Assign escort
960
+ const newEscorts = [...shipment.escortIds, unitId];
961
+ this.db.updateShipment(shipmentId, { escortIds: newEscorts });
962
+ this.db.updateUnit(unitId, { assignmentId: shipmentId });
963
+ return { success: true };
964
+ }
965
+ /**
966
+ * List a unit for sale at current location (must be at Hub)
967
+ */
968
+ listUnitForSale(playerId, unitId, price) {
969
+ const player = this.db.getPlayer(playerId);
970
+ if (!player)
971
+ return { success: false, error: 'Player not found' };
972
+ const unit = this.db.getUnit(unitId);
973
+ if (!unit)
974
+ return { success: false, error: 'Unit not found' };
975
+ if (unit.playerId !== playerId) {
976
+ return { success: false, error: 'Not your unit' };
977
+ }
978
+ if (unit.assignmentId) {
979
+ return { success: false, error: 'Unit is currently assigned. Unassign first.' };
980
+ }
981
+ const zone = this.db.getZone(unit.locationId);
982
+ if (!zone || zone.type !== 'hub') {
983
+ return { success: false, error: 'Units can only be sold at Hubs' };
984
+ }
985
+ if (price < 1) {
986
+ return { success: false, error: 'Price must be at least 1 credit' };
987
+ }
988
+ this.db.updateUnit(unitId, { forSalePrice: price });
989
+ return { success: true };
990
+ }
991
+ /**
992
+ * Remove a unit from sale
993
+ */
994
+ unlistUnit(playerId, unitId) {
995
+ const unit = this.db.getUnit(unitId);
996
+ if (!unit)
997
+ return { success: false, error: 'Unit not found' };
998
+ if (unit.playerId !== playerId) {
999
+ return { success: false, error: 'Not your unit' };
1000
+ }
1001
+ this.db.updateUnit(unitId, { forSalePrice: null });
1002
+ return { success: true };
1003
+ }
1004
+ /**
1005
+ * Hire (buy) a unit that's listed for sale
1006
+ */
1007
+ hireUnit(playerId, unitId) {
1008
+ const canAct = this.canPlayerAct(playerId);
1009
+ if (!canAct.allowed)
1010
+ return { success: false, error: canAct.reason };
1011
+ const player = this.db.getPlayer(playerId);
1012
+ if (!player)
1013
+ return { success: false, error: 'Player not found' };
1014
+ const unit = this.db.getUnit(unitId);
1015
+ if (!unit)
1016
+ return { success: false, error: 'Unit not found' };
1017
+ if (unit.forSalePrice === null) {
1018
+ return { success: false, error: 'Unit is not for sale' };
1019
+ }
1020
+ if (unit.playerId === playerId) {
1021
+ return { success: false, error: 'Cannot buy your own unit' };
1022
+ }
1023
+ // Buyer must be at the same location
1024
+ if (player.locationId !== unit.locationId) {
1025
+ return { success: false, error: 'Must be at the same Hub as the unit' };
1026
+ }
1027
+ const price = unit.forSalePrice;
1028
+ if (player.inventory.credits < price) {
1029
+ return { success: false, error: `Insufficient credits. Need ${price}` };
1030
+ }
1031
+ // Transfer credits
1032
+ const seller = this.db.getPlayer(unit.playerId);
1033
+ if (seller) {
1034
+ const sellerInventory = { ...seller.inventory };
1035
+ sellerInventory.credits += price;
1036
+ this.db.updatePlayer(seller.id, { inventory: sellerInventory });
1037
+ }
1038
+ const buyerInventory = { ...player.inventory };
1039
+ buyerInventory.credits -= price;
1040
+ this.db.updatePlayer(playerId, { inventory: buyerInventory });
1041
+ // Transfer unit ownership
1042
+ this.db.updateUnit(unitId, {
1043
+ playerId,
1044
+ forSalePrice: null
1045
+ });
1046
+ this.recordPlayerAction(playerId);
1047
+ this.recordEvent('player_action', playerId, 'player', {
1048
+ action: 'hire_unit',
1049
+ unitId,
1050
+ unitType: unit.type,
1051
+ price,
1052
+ sellerId: unit.playerId
1053
+ });
1054
+ // Get updated unit
1055
+ const updatedUnit = this.db.getUnit(unitId);
1056
+ return { success: true, unit: updatedUnit || undefined };
1057
+ }
1058
+ /**
1059
+ * Deploy a raider unit to interdict a route
1060
+ */
1061
+ deployRaider(playerId, unitId, routeId) {
1062
+ const player = this.db.getPlayer(playerId);
1063
+ if (!player)
1064
+ return { success: false, error: 'Player not found' };
1065
+ const unit = this.db.getUnit(unitId);
1066
+ if (!unit)
1067
+ return { success: false, error: 'Unit not found' };
1068
+ if (unit.playerId !== playerId) {
1069
+ return { success: false, error: 'Not your unit' };
1070
+ }
1071
+ if (unit.type !== 'raider') {
1072
+ return { success: false, error: 'Only raider units can interdict routes' };
1073
+ }
1074
+ const route = this.db.getRoute(routeId);
1075
+ if (!route)
1076
+ return { success: false, error: 'Route not found' };
1077
+ // Deploy raider
1078
+ this.db.updateUnit(unitId, { assignmentId: routeId });
1079
+ this.recordEvent('player_action', playerId, 'player', {
1080
+ action: 'deploy_raider',
1081
+ unitId,
1082
+ routeId
1083
+ });
1084
+ return { success: true };
1085
+ }
1086
+ // ============================================================================
1087
+ // UTILITY
1088
+ // ============================================================================
1089
+ findRoute(fromZoneId, toZoneId) {
1090
+ const routes = this.db.getRoutesBetween(fromZoneId, toZoneId);
1091
+ return routes.length > 0 ? routes[0] : null;
1092
+ }
1093
+ recordEvent(type, actorId, actorType, data) {
1094
+ const tick = this.db.getCurrentTick();
1095
+ return this.db.recordEvent({
1096
+ type,
1097
+ tick,
1098
+ timestamp: new Date(),
1099
+ actorId,
1100
+ actorType,
1101
+ data
1102
+ });
1103
+ }
1104
+ }