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,2274 @@
1
+ /**
2
+ * BURNRATE Async Game Engine
3
+ * Multiplayer-ready engine using TursoDatabase
4
+ */
5
+ import { getSupplyState, getZoneEfficiency, SHIPMENT_SPECS, SU_RECIPE, TIER_LIMITS, RECIPES, getFieldResource, FACTION_PERMISSIONS, LICENSE_REQUIREMENTS, REPUTATION_REWARDS, getReputationTitle, SEASON_CONFIG, TUTORIAL_CONTRACTS } from './types.js';
6
+ export class AsyncGameEngine {
7
+ db;
8
+ constructor(db) {
9
+ this.db = db;
10
+ }
11
+ // ============================================================================
12
+ // TICK PROCESSING
13
+ // ============================================================================
14
+ async processTick() {
15
+ const tick = await this.db.incrementTick();
16
+ const events = [];
17
+ events.push(await this.recordEvent('tick', null, 'system', { tick }));
18
+ const burnEvents = await this.processSupplyBurn(tick);
19
+ events.push(...burnEvents);
20
+ const shipmentEvents = await this.processShipments(tick);
21
+ events.push(...shipmentEvents);
22
+ const maintenanceEvents = await this.processUnitMaintenance(tick);
23
+ events.push(...maintenanceEvents);
24
+ const contractEvents = await this.processContractExpiration(tick);
25
+ events.push(...contractEvents);
26
+ await this.processFieldRegeneration(tick);
27
+ // Stockpile decay: medkits decay every 10 ticks, comms every 20 ticks
28
+ if (tick % 10 === 0) {
29
+ await this.processStockpileDecay(tick);
30
+ }
31
+ if (tick % 144 === 0) {
32
+ await this.resetDailyActions();
33
+ }
34
+ // Process advanced market orders
35
+ const advancedOrderEvents = await this.processAdvancedOrders(tick);
36
+ events.push(...advancedOrderEvents);
37
+ // Clean up expired intel every 50 ticks to keep database size manageable
38
+ if (tick % 50 === 0) {
39
+ await this.db.cleanupExpiredIntel(200);
40
+ }
41
+ // Update zone scores weekly
42
+ if (tick % SEASON_CONFIG.ticksPerWeek === 0) {
43
+ const season = await this.db.getSeasonInfo();
44
+ await this.db.updateSeasonZoneScores(season.seasonNumber);
45
+ // Advance week
46
+ if (season.seasonWeek < SEASON_CONFIG.weeksPerSeason) {
47
+ await this.db.advanceWeek();
48
+ }
49
+ else {
50
+ // End of season - archive scores and reset the world
51
+ const newSeasonNumber = season.seasonNumber + 1;
52
+ await this.db.seasonReset(newSeasonNumber);
53
+ events.push(await this.recordEvent('tick', null, 'system', {
54
+ type: 'season_end',
55
+ endedSeason: season.seasonNumber,
56
+ newSeason: newSeasonNumber
57
+ }));
58
+ }
59
+ }
60
+ return { tick, events };
61
+ }
62
+ // ============================================================================
63
+ // SUPPLY BURN
64
+ // ============================================================================
65
+ async processSupplyBurn(tick) {
66
+ const events = [];
67
+ const zones = await this.db.getAllZones();
68
+ for (const zone of zones) {
69
+ if (zone.burnRate === 0 || !zone.ownerId)
70
+ continue;
71
+ const previousState = getSupplyState(zone.supplyLevel);
72
+ const suNeeded = zone.burnRate;
73
+ if (zone.suStockpile >= suNeeded) {
74
+ const newStockpile = zone.suStockpile - suNeeded;
75
+ const newSupplyLevel = Math.min(100, (newStockpile / suNeeded) * 100);
76
+ const newStreak = zone.supplyLevel >= 100 ? zone.complianceStreak + 1 : 0;
77
+ await this.db.updateZone(zone.id, {
78
+ suStockpile: newStockpile,
79
+ supplyLevel: newSupplyLevel >= 100 ? 100 : newSupplyLevel,
80
+ complianceStreak: newStreak
81
+ });
82
+ events.push(await this.recordEvent('zone_supplied', zone.ownerId, 'faction', {
83
+ zoneId: zone.id,
84
+ zoneName: zone.name,
85
+ suBurned: suNeeded,
86
+ remaining: newStockpile,
87
+ supplyLevel: newSupplyLevel,
88
+ streak: newStreak
89
+ }));
90
+ }
91
+ else {
92
+ const supplied = zone.suStockpile;
93
+ const deficit = suNeeded - supplied;
94
+ const newSupplyLevel = Math.max(0, zone.supplyLevel - (deficit / suNeeded) * 25);
95
+ await this.db.updateZone(zone.id, {
96
+ suStockpile: 0,
97
+ supplyLevel: newSupplyLevel,
98
+ complianceStreak: 0
99
+ });
100
+ const newState = getSupplyState(newSupplyLevel);
101
+ if (newState !== previousState) {
102
+ events.push(await this.recordEvent('zone_state_changed', zone.ownerId, 'faction', {
103
+ zoneId: zone.id,
104
+ zoneName: zone.name,
105
+ previousState,
106
+ newState,
107
+ supplyLevel: newSupplyLevel
108
+ }));
109
+ if (newState === 'collapsed') {
110
+ await this.db.updateZone(zone.id, { ownerId: null });
111
+ events.push(await this.recordEvent('zone_captured', null, 'system', {
112
+ zoneId: zone.id,
113
+ zoneName: zone.name,
114
+ previousOwner: zone.ownerId,
115
+ newOwner: null,
116
+ reason: 'supply_collapse'
117
+ }));
118
+ }
119
+ }
120
+ }
121
+ }
122
+ return events;
123
+ }
124
+ // ============================================================================
125
+ // SHIPMENT PROCESSING
126
+ // ============================================================================
127
+ async processShipments(tick) {
128
+ const events = [];
129
+ const shipments = await this.db.getActiveShipments();
130
+ for (const shipment of shipments) {
131
+ const newTicks = shipment.ticksToNextZone - 1;
132
+ if (newTicks <= 0) {
133
+ const newPosition = shipment.currentPosition + 1;
134
+ if (newPosition >= shipment.path.length) {
135
+ await this.completeShipment(shipment, tick, events);
136
+ }
137
+ else {
138
+ const fromZoneId = shipment.path[shipment.currentPosition];
139
+ const toZoneId = shipment.path[newPosition];
140
+ const intercepted = await this.checkInterception(shipment, fromZoneId, toZoneId, tick);
141
+ if (intercepted) {
142
+ await this.interceptShipment(shipment, tick, events);
143
+ }
144
+ else {
145
+ const route = await this.findRoute(fromZoneId, toZoneId);
146
+ const spec = SHIPMENT_SPECS[shipment.type];
147
+ const ticksToNext = route
148
+ ? Math.ceil(route.distance * spec.speedModifier)
149
+ : 1;
150
+ await this.db.updateShipment(shipment.id, {
151
+ currentPosition: newPosition,
152
+ ticksToNextZone: ticksToNext
153
+ });
154
+ events.push(await this.recordEvent('shipment_moved', shipment.playerId, 'player', {
155
+ shipmentId: shipment.id,
156
+ fromZone: fromZoneId,
157
+ toZone: toZoneId,
158
+ position: newPosition,
159
+ totalLegs: shipment.path.length - 1
160
+ }));
161
+ }
162
+ }
163
+ }
164
+ else {
165
+ await this.db.updateShipment(shipment.id, { ticksToNextZone: newTicks });
166
+ }
167
+ }
168
+ return events;
169
+ }
170
+ async completeShipment(shipment, tick, events) {
171
+ const destinationId = shipment.path[shipment.path.length - 1];
172
+ const player = await this.db.getPlayer(shipment.playerId);
173
+ if (player) {
174
+ const newInventory = { ...player.inventory };
175
+ for (const [resource, amount] of Object.entries(shipment.cargo)) {
176
+ if (amount && resource in newInventory) {
177
+ newInventory[resource] += amount;
178
+ }
179
+ }
180
+ await this.db.updatePlayer(player.id, { inventory: newInventory, locationId: destinationId });
181
+ // Award reputation for successful delivery
182
+ await this.awardReputation(player.id, REPUTATION_REWARDS.shipmentDelivered, 'shipment_delivered');
183
+ // Track season score
184
+ const season = await this.db.getSeasonInfo();
185
+ await this.db.incrementSeasonScore(season.seasonNumber, player.id, 'player', player.name, 'shipmentsCompleted', 1);
186
+ }
187
+ await this.db.updateShipment(shipment.id, { status: 'arrived' });
188
+ events.push(await this.recordEvent('shipment_arrived', shipment.playerId, 'player', {
189
+ shipmentId: shipment.id,
190
+ destination: destinationId,
191
+ cargo: shipment.cargo
192
+ }));
193
+ }
194
+ async checkInterception(shipment, fromZoneId, toZoneId, tick) {
195
+ const route = await this.findRoute(fromZoneId, toZoneId);
196
+ if (!route)
197
+ return false;
198
+ const spec = SHIPMENT_SPECS[shipment.type];
199
+ let interceptionChance = route.baseRisk * route.chokepointRating * spec.visibilityModifier;
200
+ const raiders = await this.getRaidersOnRoute(route.id);
201
+ const totalRaiderStrength = raiders.reduce((sum, r) => sum + r.strength, 0);
202
+ if (totalRaiderStrength > 0) {
203
+ interceptionChance += totalRaiderStrength * 0.05;
204
+ }
205
+ let totalEscortStrength = 0;
206
+ for (const id of shipment.escortIds) {
207
+ const unit = await this.db.getUnit(id);
208
+ totalEscortStrength += unit?.strength || 0;
209
+ }
210
+ if (totalEscortStrength >= totalRaiderStrength) {
211
+ interceptionChance *= Math.max(0.1, 1 - totalEscortStrength * 0.1);
212
+ }
213
+ else {
214
+ interceptionChance *= Math.max(0.2, 1 - (totalEscortStrength - totalRaiderStrength) * 0.05);
215
+ }
216
+ // Front efficiency: destination zone's raid resistance reduces interception
217
+ const destZone = await this.db.getZone(toZoneId);
218
+ if (destZone && destZone.ownerId) {
219
+ const efficiency = getZoneEfficiency(destZone.supplyLevel, destZone.complianceStreak, destZone.medkitStockpile, destZone.commsStockpile);
220
+ // raidResistance > 1.0 reduces chance, < 1.0 increases it
221
+ interceptionChance /= Math.max(0.1, efficiency.raidResistance);
222
+ }
223
+ interceptionChance = Math.min(0.95, interceptionChance);
224
+ return Math.random() < interceptionChance;
225
+ }
226
+ async getRaidersOnRoute(routeId) {
227
+ const allPlayers = await this.db.getAllPlayers();
228
+ const raiders = [];
229
+ for (const player of allPlayers) {
230
+ const units = await this.db.getPlayerUnits(player.id);
231
+ for (const unit of units) {
232
+ if (unit.type === 'raider' && unit.assignmentId === routeId) {
233
+ raiders.push(unit);
234
+ }
235
+ }
236
+ }
237
+ return raiders;
238
+ }
239
+ async interceptShipment(shipment, tick, events) {
240
+ const fromZoneId = shipment.path[shipment.currentPosition];
241
+ const toZoneId = shipment.path[shipment.currentPosition + 1];
242
+ const route = await this.findRoute(fromZoneId, toZoneId);
243
+ const escorts = [];
244
+ for (const id of shipment.escortIds) {
245
+ const unit = await this.db.getUnit(id);
246
+ if (unit)
247
+ escorts.push(unit);
248
+ }
249
+ const raiders = route ? await this.getRaidersOnRoute(route.id) : [];
250
+ const totalEscortStrength = escorts.reduce((sum, u) => sum + u.strength, 0);
251
+ const totalRaiderStrength = raiders.reduce((sum, u) => sum + u.strength, 0);
252
+ // Medkit bonus: zone's medkit stockpile reduces effective raider strength
253
+ const destZone = await this.db.getZone(toZoneId);
254
+ let medkitBonus = 0;
255
+ if (destZone) {
256
+ const efficiency = getZoneEfficiency(destZone.supplyLevel, destZone.complianceStreak, destZone.medkitStockpile, destZone.commsStockpile);
257
+ medkitBonus = efficiency.medkitBonus;
258
+ }
259
+ // Effective escort strength boosted by medkit bonus (up to +50%)
260
+ const effectiveEscortStrength = totalEscortStrength * (1 + medkitBonus);
261
+ let cargoLossPct = 0.5;
262
+ const combatEvents = [];
263
+ if (totalRaiderStrength > 0 && effectiveEscortStrength > 0) {
264
+ const escortAdvantage = effectiveEscortStrength - totalRaiderStrength;
265
+ if (escortAdvantage > 10) {
266
+ cargoLossPct = 0.1;
267
+ for (const raider of raiders.slice(0, Math.floor(raiders.length / 2))) {
268
+ await this.db.deleteUnit(raider.id);
269
+ combatEvents.push({ type: 'raider_destroyed', unitId: raider.id });
270
+ }
271
+ }
272
+ else if (escortAdvantage > 0) {
273
+ cargoLossPct = 0.25;
274
+ }
275
+ else if (escortAdvantage > -10) {
276
+ cargoLossPct = 0.5;
277
+ for (const escort of escorts.slice(0, 1)) {
278
+ await this.db.deleteUnit(escort.id);
279
+ combatEvents.push({ type: 'escort_destroyed', unitId: escort.id });
280
+ }
281
+ }
282
+ else {
283
+ cargoLossPct = 0.75;
284
+ for (const escort of escorts.slice(0, Math.ceil(escorts.length * 0.75))) {
285
+ await this.db.deleteUnit(escort.id);
286
+ combatEvents.push({ type: 'escort_destroyed', unitId: escort.id });
287
+ }
288
+ }
289
+ events.push(await this.recordEvent('combat_resolved', null, 'system', {
290
+ location: fromZoneId,
291
+ escortStrength: totalEscortStrength,
292
+ raiderStrength: totalRaiderStrength,
293
+ outcome: escortAdvantage > 0 ? 'escort_victory' : 'raider_victory',
294
+ casualties: combatEvents
295
+ }));
296
+ }
297
+ const lostCargo = {};
298
+ const remainingCargo = {};
299
+ for (const [resource, amount] of Object.entries(shipment.cargo)) {
300
+ if (amount) {
301
+ const lost = Math.floor(amount * cargoLossPct);
302
+ lostCargo[resource] = lost;
303
+ remainingCargo[resource] = amount - lost;
304
+ }
305
+ }
306
+ const survivingEscorts = [];
307
+ for (const id of shipment.escortIds) {
308
+ const unit = await this.db.getUnit(id);
309
+ if (unit)
310
+ survivingEscorts.push(id);
311
+ }
312
+ if (cargoLossPct < 1 && Object.values(remainingCargo).some(v => v && v > 0)) {
313
+ await this.db.updateShipment(shipment.id, { escortIds: survivingEscorts });
314
+ // Partial loss - smaller reputation penalty
315
+ await this.awardReputation(shipment.playerId, Math.floor(REPUTATION_REWARDS.shipmentIntercepted / 2), 'shipment_partial_loss');
316
+ events.push(await this.recordEvent('shipment_intercepted', shipment.playerId, 'player', {
317
+ shipmentId: shipment.id,
318
+ lostCargo,
319
+ remainingCargo,
320
+ location: fromZoneId,
321
+ outcome: 'partial_loss',
322
+ cargoLossPct: Math.round(cargoLossPct * 100)
323
+ }));
324
+ }
325
+ else {
326
+ await this.db.updateShipment(shipment.id, { status: 'intercepted' });
327
+ // Total loss - full reputation penalty
328
+ await this.awardReputation(shipment.playerId, REPUTATION_REWARDS.shipmentIntercepted, 'shipment_total_loss');
329
+ events.push(await this.recordEvent('shipment_intercepted', shipment.playerId, 'player', {
330
+ shipmentId: shipment.id,
331
+ lostCargo,
332
+ location: fromZoneId,
333
+ outcome: 'total_loss'
334
+ }));
335
+ }
336
+ }
337
+ // ============================================================================
338
+ // UNIT MAINTENANCE
339
+ // ============================================================================
340
+ async processUnitMaintenance(tick) {
341
+ const events = [];
342
+ const players = await this.db.getAllPlayers();
343
+ for (const player of players) {
344
+ const units = await this.db.getPlayerUnits(player.id);
345
+ let totalMaintenance = 0;
346
+ for (const unit of units) {
347
+ totalMaintenance += unit.maintenance;
348
+ }
349
+ if (totalMaintenance > 0 && player.inventory.credits >= totalMaintenance) {
350
+ const newInventory = { ...player.inventory };
351
+ newInventory.credits -= totalMaintenance;
352
+ await this.db.updatePlayer(player.id, { inventory: newInventory });
353
+ }
354
+ else if (totalMaintenance > player.inventory.credits) {
355
+ if (units.length > 0) {
356
+ const unitToDelete = units[0];
357
+ await this.db.deleteUnit(unitToDelete.id);
358
+ events.push(await this.recordEvent('player_action', player.id, 'player', {
359
+ action: 'unit_deserted',
360
+ unitId: unitToDelete.id,
361
+ reason: 'maintenance_unpaid'
362
+ }));
363
+ }
364
+ }
365
+ }
366
+ return events;
367
+ }
368
+ // ============================================================================
369
+ // CONTRACT EXPIRATION
370
+ // ============================================================================
371
+ async processContractExpiration(tick) {
372
+ const events = [];
373
+ const contracts = await this.db.getOpenContracts();
374
+ for (const contract of contracts) {
375
+ if (contract.deadline < tick && contract.status === 'open') {
376
+ await this.db.updateContract(contract.id, { status: 'expired' });
377
+ }
378
+ else if (contract.deadline < tick && contract.status === 'active') {
379
+ await this.db.updateContract(contract.id, { status: 'failed' });
380
+ events.push(await this.recordEvent('contract_failed', contract.acceptedBy, 'player', {
381
+ contractId: contract.id,
382
+ reason: 'deadline_missed'
383
+ }));
384
+ }
385
+ }
386
+ return events;
387
+ }
388
+ // ============================================================================
389
+ // DAILY RESET & FIELD REGENERATION
390
+ // ============================================================================
391
+ async resetDailyActions() {
392
+ const players = await this.db.getAllPlayers();
393
+ for (const player of players) {
394
+ await this.db.updatePlayer(player.id, { actionsToday: 0 });
395
+ }
396
+ }
397
+ async processFieldRegeneration(tick) {
398
+ const zones = await this.db.getAllZones();
399
+ for (const zone of zones) {
400
+ if (zone.type !== 'field')
401
+ continue;
402
+ const resource = getFieldResource(zone.name);
403
+ if (!resource)
404
+ continue;
405
+ const maxStockpile = 1000;
406
+ const regenAmount = Math.floor(zone.productionCapacity / 10);
407
+ const currentAmount = zone.inventory[resource] || 0;
408
+ if (currentAmount < maxStockpile) {
409
+ const newAmount = Math.min(maxStockpile, currentAmount + regenAmount);
410
+ const newInventory = { ...zone.inventory };
411
+ newInventory[resource] = newAmount;
412
+ await this.db.updateZone(zone.id, { inventory: newInventory });
413
+ }
414
+ }
415
+ }
416
+ async processStockpileDecay(tick) {
417
+ const zones = await this.db.getAllZones();
418
+ for (const zone of zones) {
419
+ const updates = {};
420
+ // Medkits decay 1 per 10 ticks
421
+ if (zone.medkitStockpile > 0) {
422
+ updates.medkitStockpile = Math.max(0, zone.medkitStockpile - 1);
423
+ }
424
+ // Comms decay 1 per 20 ticks (called every 10, so decay every other call)
425
+ if (zone.commsStockpile > 0 && tick % 20 === 0) {
426
+ updates.commsStockpile = Math.max(0, zone.commsStockpile - 1);
427
+ }
428
+ if (Object.keys(updates).length > 0) {
429
+ await this.db.updateZone(zone.id, updates);
430
+ }
431
+ }
432
+ }
433
+ // ============================================================================
434
+ // PLAYER ACTIONS
435
+ // ============================================================================
436
+ async canPlayerAct(playerId) {
437
+ const player = await this.db.getPlayer(playerId);
438
+ if (!player)
439
+ return { allowed: false, reason: 'Player not found' };
440
+ const tick = await this.db.getCurrentTick();
441
+ const limits = TIER_LIMITS[player.tier];
442
+ if (player.actionsToday >= limits.dailyActions) {
443
+ return { allowed: false, reason: `Daily action limit (${limits.dailyActions}) reached` };
444
+ }
445
+ if (player.lastActionTick >= tick) {
446
+ return { allowed: false, reason: 'Rate limited. Wait for next tick.' };
447
+ }
448
+ return { allowed: true };
449
+ }
450
+ async recordPlayerAction(playerId) {
451
+ const player = await this.db.getPlayer(playerId);
452
+ if (!player)
453
+ return;
454
+ const tick = await this.db.getCurrentTick();
455
+ await this.db.updatePlayer(playerId, {
456
+ actionsToday: player.actionsToday + 1,
457
+ lastActionTick: tick
458
+ });
459
+ }
460
+ async createShipmentWithPath(playerId, type, path, cargo) {
461
+ const canAct = await this.canPlayerAct(playerId);
462
+ if (!canAct.allowed)
463
+ return { success: false, error: canAct.reason };
464
+ const player = await this.db.getPlayer(playerId);
465
+ if (!player)
466
+ return { success: false, error: 'Player not found' };
467
+ if (path.length < 2) {
468
+ return { success: false, error: 'Path must have at least origin and destination' };
469
+ }
470
+ const fromZoneId = path[0];
471
+ const toZoneId = path[path.length - 1];
472
+ if (!player.licenses[type]) {
473
+ return { success: false, error: `No ${type} license` };
474
+ }
475
+ if (player.locationId !== fromZoneId) {
476
+ return { success: false, error: 'Must be at origin zone' };
477
+ }
478
+ const spec = SHIPMENT_SPECS[type];
479
+ const totalCargo = Object.values(cargo).reduce((sum, v) => sum + (v || 0), 0);
480
+ if (totalCargo > spec.capacity) {
481
+ return { success: false, error: `Cargo (${totalCargo}) exceeds ${type} capacity (${spec.capacity})` };
482
+ }
483
+ for (const [resource, amount] of Object.entries(cargo)) {
484
+ if (amount && player.inventory[resource] < amount) {
485
+ return { success: false, error: `Insufficient ${resource}` };
486
+ }
487
+ }
488
+ for (let i = 0; i < path.length - 1; i++) {
489
+ const routes = await this.db.getRoutesBetween(path[i], path[i + 1]);
490
+ if (routes.length === 0) {
491
+ return { success: false, error: `No direct route for leg ${i + 1}` };
492
+ }
493
+ }
494
+ const firstRoutes = await this.db.getRoutesBetween(path[0], path[1]);
495
+ const firstRoute = firstRoutes[0];
496
+ const ticksToNext = Math.ceil(firstRoute.distance * spec.speedModifier);
497
+ const newInventory = { ...player.inventory };
498
+ for (const [resource, amount] of Object.entries(cargo)) {
499
+ if (amount) {
500
+ newInventory[resource] -= amount;
501
+ }
502
+ }
503
+ await this.db.updatePlayer(playerId, { inventory: newInventory });
504
+ const tick = await this.db.getCurrentTick();
505
+ const shipment = await 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
+ await this.recordPlayerAction(playerId);
517
+ await 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
+ async placeOrder(playerId, zoneId, resource, side, price, quantity) {
528
+ const canAct = await this.canPlayerAct(playerId);
529
+ if (!canAct.allowed)
530
+ return { success: false, error: canAct.reason };
531
+ const player = await this.db.getPlayer(playerId);
532
+ if (!player)
533
+ return { success: false, error: 'Player not found' };
534
+ if (player.locationId !== zoneId) {
535
+ return { success: false, error: 'Must be at zone to trade' };
536
+ }
537
+ const limits = TIER_LIMITS[player.tier];
538
+ const allOrders = await this.db.getOrdersForZone(zoneId);
539
+ const existingOrders = allOrders.filter(o => o.playerId === playerId);
540
+ if (existingOrders.length >= limits.marketOrders) {
541
+ return { success: false, error: `Order limit (${limits.marketOrders}) reached` };
542
+ }
543
+ if (side === 'sell') {
544
+ if (player.inventory[resource] < quantity) {
545
+ return { success: false, error: `Insufficient ${resource}` };
546
+ }
547
+ const newInventory = { ...player.inventory };
548
+ newInventory[resource] -= quantity;
549
+ await this.db.updatePlayer(playerId, { inventory: newInventory });
550
+ }
551
+ else {
552
+ const totalCost = price * quantity;
553
+ if (player.inventory.credits < totalCost) {
554
+ return { success: false, error: 'Insufficient credits' };
555
+ }
556
+ const newInventory = { ...player.inventory };
557
+ newInventory.credits -= totalCost;
558
+ await this.db.updatePlayer(playerId, { inventory: newInventory });
559
+ }
560
+ const tick = await this.db.getCurrentTick();
561
+ const order = await this.db.createOrder({
562
+ playerId,
563
+ zoneId,
564
+ resource,
565
+ side,
566
+ price,
567
+ quantity,
568
+ originalQuantity: quantity,
569
+ createdAt: tick
570
+ });
571
+ await this.recordPlayerAction(playerId);
572
+ await this.matchOrders(zoneId, resource);
573
+ await this.recordEvent('order_placed', playerId, 'player', {
574
+ orderId: order.id,
575
+ resource,
576
+ side,
577
+ price,
578
+ quantity
579
+ });
580
+ return { success: true, order };
581
+ }
582
+ async matchOrders(zoneId, resource) {
583
+ const orders = await this.db.getOrdersForZone(zoneId, resource);
584
+ const buys = orders.filter(o => o.side === 'buy').sort((a, b) => b.price - a.price);
585
+ const sells = orders.filter(o => o.side === 'sell').sort((a, b) => a.price - b.price);
586
+ for (const buy of buys) {
587
+ for (const sell of sells) {
588
+ if (buy.price >= sell.price && buy.quantity > 0 && sell.quantity > 0) {
589
+ const tradeQty = Math.min(buy.quantity, sell.quantity);
590
+ const tradePrice = sell.price;
591
+ await this.executeTrade(buy, sell, tradeQty, tradePrice);
592
+ }
593
+ }
594
+ }
595
+ }
596
+ async executeTrade(buyOrder, sellOrder, quantity, price) {
597
+ await this.db.updateOrder(buyOrder.id, buyOrder.quantity - quantity);
598
+ await this.db.updateOrder(sellOrder.id, sellOrder.quantity - quantity);
599
+ const buyer = await this.db.getPlayer(buyOrder.playerId);
600
+ if (buyer) {
601
+ const newInventory = { ...buyer.inventory };
602
+ newInventory[buyOrder.resource] += quantity;
603
+ const refund = (buyOrder.price - price) * quantity;
604
+ newInventory.credits += refund;
605
+ await this.db.updatePlayer(buyer.id, { inventory: newInventory });
606
+ }
607
+ const seller = await this.db.getPlayer(sellOrder.playerId);
608
+ if (seller) {
609
+ const newInventory = { ...seller.inventory };
610
+ newInventory.credits += price * quantity;
611
+ await this.db.updatePlayer(seller.id, { inventory: newInventory });
612
+ }
613
+ await this.recordEvent('trade_executed', null, 'system', {
614
+ zoneId: buyOrder.zoneId,
615
+ resource: buyOrder.resource,
616
+ buyerId: buyOrder.playerId,
617
+ sellerId: sellOrder.playerId,
618
+ price,
619
+ quantity
620
+ });
621
+ }
622
+ async depositSU(playerId, zoneId, amount) {
623
+ const canAct = await this.canPlayerAct(playerId);
624
+ if (!canAct.allowed)
625
+ return { success: false, error: canAct.reason };
626
+ const player = await this.db.getPlayer(playerId);
627
+ if (!player)
628
+ return { success: false, error: 'Player not found' };
629
+ const zone = await this.db.getZone(zoneId);
630
+ if (!zone)
631
+ return { success: false, error: 'Zone not found' };
632
+ if (player.locationId !== zoneId) {
633
+ return { success: false, error: 'Must be at zone' };
634
+ }
635
+ const needed = {
636
+ rations: SU_RECIPE.rations * amount,
637
+ fuel: SU_RECIPE.fuel * amount,
638
+ parts: SU_RECIPE.parts * amount,
639
+ ammo: SU_RECIPE.ammo * amount
640
+ };
641
+ for (const [resource, qty] of Object.entries(needed)) {
642
+ if (player.inventory[resource] < qty) {
643
+ return { success: false, error: `Insufficient ${resource} for ${amount} SU` };
644
+ }
645
+ }
646
+ const newInventory = { ...player.inventory };
647
+ newInventory.rations -= needed.rations;
648
+ newInventory.fuel -= needed.fuel;
649
+ newInventory.parts -= needed.parts;
650
+ newInventory.ammo -= needed.ammo;
651
+ await this.db.updatePlayer(playerId, { inventory: newInventory });
652
+ await this.db.updateZone(zoneId, { suStockpile: zone.suStockpile + amount });
653
+ await this.recordPlayerAction(playerId);
654
+ await this.recordEvent('zone_supplied', playerId, 'player', {
655
+ zoneId,
656
+ amount,
657
+ newStockpile: zone.suStockpile + amount
658
+ });
659
+ return { success: true };
660
+ }
661
+ async depositStockpile(playerId, zoneId, resource, amount) {
662
+ const canAct = await this.canPlayerAct(playerId);
663
+ if (!canAct.allowed)
664
+ return { success: false, error: canAct.reason };
665
+ const player = await this.db.getPlayer(playerId);
666
+ if (!player)
667
+ return { success: false, error: 'Player not found' };
668
+ const zone = await this.db.getZone(zoneId);
669
+ if (!zone)
670
+ return { success: false, error: 'Zone not found' };
671
+ if (player.locationId !== zoneId) {
672
+ return { success: false, error: 'Must be at zone' };
673
+ }
674
+ if (!zone.ownerId) {
675
+ return { success: false, error: 'Zone must be controlled by a faction' };
676
+ }
677
+ if (player.inventory[resource] < amount) {
678
+ return { success: false, error: `Insufficient ${resource}. Have ${player.inventory[resource]}, need ${amount}` };
679
+ }
680
+ const newInventory = { ...player.inventory };
681
+ newInventory[resource] -= amount;
682
+ await this.db.updatePlayer(playerId, { inventory: newInventory });
683
+ const fieldName = resource === 'medkits' ? 'medkitStockpile' : 'commsStockpile';
684
+ const currentStockpile = resource === 'medkits' ? zone.medkitStockpile : zone.commsStockpile;
685
+ await this.db.updateZone(zoneId, { [fieldName]: currentStockpile + amount });
686
+ await this.recordPlayerAction(playerId);
687
+ await this.recordEvent('stockpile_deposited', playerId, 'player', {
688
+ zoneId,
689
+ zoneName: zone.name,
690
+ resource,
691
+ amount,
692
+ newStockpile: currentStockpile + amount
693
+ });
694
+ return { success: true };
695
+ }
696
+ async getZoneEfficiency(zoneId) {
697
+ const zone = await this.db.getZone(zoneId);
698
+ if (!zone)
699
+ return { success: false, error: 'Zone not found' };
700
+ const efficiency = getZoneEfficiency(zone.supplyLevel, zone.complianceStreak, zone.medkitStockpile, zone.commsStockpile);
701
+ return { success: true, efficiency };
702
+ }
703
+ async scan(playerId, targetType, targetId) {
704
+ const canAct = await this.canPlayerAct(playerId);
705
+ if (!canAct.allowed)
706
+ return { success: false, error: canAct.reason };
707
+ const player = await this.db.getPlayer(playerId);
708
+ if (!player)
709
+ return { success: false, error: 'Player not found' };
710
+ const tick = await this.db.getCurrentTick();
711
+ let data = {};
712
+ if (targetType === 'zone') {
713
+ const zone = await this.db.getZone(targetId);
714
+ if (!zone)
715
+ return { success: false, error: 'Zone not found' };
716
+ let ownerName = null;
717
+ if (zone.ownerId) {
718
+ const ownerFaction = await this.db.getFaction(zone.ownerId);
719
+ ownerName = ownerFaction ? `${ownerFaction.name} [${ownerFaction.tag}]` : zone.ownerId;
720
+ }
721
+ const orders = await this.db.getOrdersForZone(targetId);
722
+ data = {
723
+ name: zone.name,
724
+ type: zone.type,
725
+ owner: zone.ownerId,
726
+ ownerName,
727
+ supplyState: getSupplyState(zone.supplyLevel, zone.complianceStreak),
728
+ supplyLevel: zone.supplyLevel,
729
+ suStockpile: zone.suStockpile,
730
+ burnRate: zone.burnRate,
731
+ marketActivity: orders.length,
732
+ garrisonLevel: zone.garrisonLevel,
733
+ medkitStockpile: zone.medkitStockpile,
734
+ commsStockpile: zone.commsStockpile,
735
+ complianceStreak: zone.complianceStreak
736
+ };
737
+ }
738
+ else {
739
+ const route = await this.db.getRoute(targetId);
740
+ if (!route)
741
+ return { success: false, error: 'Route not found' };
742
+ const shipments = await this.db.getActiveShipments();
743
+ const routeShipments = shipments.filter(s => s.path.includes(route.fromZoneId) && s.path.includes(route.toZoneId));
744
+ const raiders = await this.getRaidersOnRoute(route.id);
745
+ data = {
746
+ from: route.fromZoneId,
747
+ to: route.toZoneId,
748
+ distance: route.distance,
749
+ baseRisk: route.baseRisk,
750
+ chokepointRating: route.chokepointRating,
751
+ activeShipments: routeShipments.length,
752
+ raiderPresence: raiders.length > 0,
753
+ raiderStrength: raiders.reduce((sum, r) => sum + r.strength, 0)
754
+ };
755
+ }
756
+ // Comms defense: target zone's comms stockpile degrades scan quality
757
+ let signalQuality = 100;
758
+ if (targetType === 'zone') {
759
+ const targetZone = await this.db.getZone(targetId);
760
+ if (targetZone && targetZone.ownerId && targetZone.ownerId !== player.factionId) {
761
+ const efficiency = getZoneEfficiency(targetZone.supplyLevel, targetZone.complianceStreak, targetZone.medkitStockpile, targetZone.commsStockpile);
762
+ // commsDefense 0-0.5 reduces signal quality proportionally
763
+ signalQuality = Math.round(100 * (1 - efficiency.commsDefense));
764
+ }
765
+ }
766
+ await this.db.createIntel({
767
+ playerId,
768
+ factionId: player.factionId,
769
+ targetType,
770
+ targetId,
771
+ gatheredAt: tick,
772
+ data,
773
+ signalQuality
774
+ });
775
+ await this.recordPlayerAction(playerId);
776
+ await this.recordEvent('intel_gathered', playerId, 'player', {
777
+ targetType,
778
+ targetId,
779
+ signalQuality,
780
+ sharedWithFaction: !!player.factionId
781
+ });
782
+ return { success: true, intel: data };
783
+ }
784
+ async produce(playerId, output, quantity) {
785
+ const canAct = await this.canPlayerAct(playerId);
786
+ if (!canAct.allowed)
787
+ return { success: false, error: canAct.reason };
788
+ const player = await this.db.getPlayer(playerId);
789
+ if (!player)
790
+ return { success: false, error: 'Player not found' };
791
+ const zone = await this.db.getZone(player.locationId);
792
+ if (!zone)
793
+ return { success: false, error: 'Zone not found' };
794
+ if (zone.type !== 'factory') {
795
+ return { success: false, error: 'Can only produce at Factories' };
796
+ }
797
+ const recipe = RECIPES[output];
798
+ if (!recipe) {
799
+ return { success: false, error: `Unknown product: ${output}` };
800
+ }
801
+ for (const [resource, needed] of Object.entries(recipe.inputs)) {
802
+ const totalNeeded = (needed || 0) * quantity;
803
+ if (player.inventory[resource] < totalNeeded) {
804
+ return { success: false, error: `Insufficient ${resource}. Need ${totalNeeded}, have ${player.inventory[resource]}` };
805
+ }
806
+ }
807
+ const newInventory = { ...player.inventory };
808
+ for (const [resource, needed] of Object.entries(recipe.inputs)) {
809
+ newInventory[resource] -= (needed || 0) * quantity;
810
+ }
811
+ // Production bonus from zone efficiency (fortified/high-streak zones produce more)
812
+ const efficiency = getZoneEfficiency(zone.supplyLevel, zone.complianceStreak, zone.medkitStockpile, zone.commsStockpile);
813
+ const bonusQuantity = Math.floor(quantity * efficiency.productionBonus);
814
+ const totalOutput = quantity + bonusQuantity;
815
+ if (recipe.isUnit) {
816
+ await this.db.updatePlayer(playerId, { inventory: newInventory });
817
+ const units = [];
818
+ const unitType = output;
819
+ for (let i = 0; i < totalOutput; i++) {
820
+ const unit = await this.db.createUnit({
821
+ playerId,
822
+ type: unitType,
823
+ locationId: player.locationId,
824
+ strength: unitType === 'escort' ? 10 : 15,
825
+ speed: unitType === 'escort' ? 1 : 2,
826
+ maintenance: unitType === 'escort' ? 5 : 8,
827
+ assignmentId: null,
828
+ forSalePrice: null
829
+ });
830
+ units.push(unit);
831
+ }
832
+ await this.recordPlayerAction(playerId);
833
+ await this.recordEvent('player_action', playerId, 'player', {
834
+ action: 'produce_unit',
835
+ unitType,
836
+ quantity: totalOutput,
837
+ bonusFromEfficiency: bonusQuantity,
838
+ zone: zone.name
839
+ });
840
+ return { success: true, produced: totalOutput, units };
841
+ }
842
+ newInventory[output] += totalOutput;
843
+ await this.db.updatePlayer(playerId, { inventory: newInventory });
844
+ await this.recordPlayerAction(playerId);
845
+ await this.recordEvent('player_action', playerId, 'player', {
846
+ action: 'produce',
847
+ output,
848
+ quantity: totalOutput,
849
+ bonusFromEfficiency: bonusQuantity,
850
+ zone: zone.name
851
+ });
852
+ return { success: true, produced: totalOutput };
853
+ }
854
+ async extract(playerId, quantity) {
855
+ const canAct = await this.canPlayerAct(playerId);
856
+ if (!canAct.allowed)
857
+ return { success: false, error: canAct.reason };
858
+ const player = await this.db.getPlayer(playerId);
859
+ if (!player)
860
+ return { success: false, error: 'Player not found' };
861
+ const zone = await this.db.getZone(player.locationId);
862
+ if (!zone)
863
+ return { success: false, error: 'Zone not found' };
864
+ if (zone.type !== 'field') {
865
+ return { success: false, error: 'Can only extract at Fields' };
866
+ }
867
+ const resource = getFieldResource(zone.name);
868
+ if (!resource) {
869
+ return { success: false, error: 'This field has no extractable resources' };
870
+ }
871
+ if (zone.inventory[resource] < quantity) {
872
+ return { success: false, error: `Insufficient ${resource} in field. Available: ${zone.inventory[resource]}` };
873
+ }
874
+ const extractionCost = 5 * quantity;
875
+ if (player.inventory.credits < extractionCost) {
876
+ return { success: false, error: `Insufficient credits. Extraction costs ${extractionCost} cr` };
877
+ }
878
+ const newZoneInventory = { ...zone.inventory };
879
+ newZoneInventory[resource] -= quantity;
880
+ await this.db.updateZone(zone.id, { inventory: newZoneInventory });
881
+ const newPlayerInventory = { ...player.inventory };
882
+ newPlayerInventory[resource] += quantity;
883
+ newPlayerInventory.credits -= extractionCost;
884
+ await this.db.updatePlayer(playerId, { inventory: newPlayerInventory });
885
+ await this.recordPlayerAction(playerId);
886
+ await this.recordEvent('player_action', playerId, 'player', {
887
+ action: 'extract',
888
+ resource,
889
+ quantity,
890
+ zone: zone.name
891
+ });
892
+ return { success: true, extracted: { resource, amount: quantity } };
893
+ }
894
+ async captureZone(playerId, zoneId) {
895
+ const canAct = await this.canPlayerAct(playerId);
896
+ if (!canAct.allowed)
897
+ return { success: false, error: canAct.reason };
898
+ const player = await this.db.getPlayer(playerId);
899
+ if (!player)
900
+ return { success: false, error: 'Player not found' };
901
+ if (!player.factionId) {
902
+ return { success: false, error: 'Must be in a faction to capture zones' };
903
+ }
904
+ const zone = await this.db.getZone(zoneId);
905
+ if (!zone)
906
+ return { success: false, error: 'Zone not found' };
907
+ if (player.locationId !== zoneId) {
908
+ return { success: false, error: 'Must be at the zone to capture it' };
909
+ }
910
+ if (zone.ownerId) {
911
+ if (zone.ownerId === player.factionId) {
912
+ return { success: false, error: 'Zone already controlled by your faction' };
913
+ }
914
+ // Front efficiency: capture defense check
915
+ const efficiency = getZoneEfficiency(zone.supplyLevel, zone.complianceStreak, zone.medkitStockpile, zone.commsStockpile);
916
+ // Fortified/supplied zones require supply collapse
917
+ if (efficiency.captureDefense > 0 && zone.supplyLevel > 0) {
918
+ return { success: false, error: 'Zone is defended. Supply must collapse before capture.' };
919
+ }
920
+ }
921
+ await this.db.updateZone(zoneId, {
922
+ ownerId: player.factionId,
923
+ supplyLevel: 0,
924
+ complianceStreak: 0,
925
+ medkitStockpile: 0,
926
+ commsStockpile: 0
927
+ });
928
+ await this.recordPlayerAction(playerId);
929
+ await this.recordEvent('zone_captured', player.factionId, 'faction', {
930
+ zoneId,
931
+ zoneName: zone.name,
932
+ previousOwner: zone.ownerId,
933
+ newOwner: player.factionId,
934
+ capturedBy: playerId
935
+ });
936
+ return { success: true };
937
+ }
938
+ async assignEscort(playerId, unitId, shipmentId) {
939
+ const player = await this.db.getPlayer(playerId);
940
+ if (!player)
941
+ return { success: false, error: 'Player not found' };
942
+ const unit = await this.db.getUnit(unitId);
943
+ if (!unit)
944
+ return { success: false, error: 'Unit not found' };
945
+ if (unit.playerId !== playerId) {
946
+ return { success: false, error: 'Not your unit' };
947
+ }
948
+ if (unit.type !== 'escort') {
949
+ return { success: false, error: 'Only escort units can be assigned to shipments' };
950
+ }
951
+ const shipment = await this.db.getShipment(shipmentId);
952
+ if (!shipment)
953
+ return { success: false, error: 'Shipment not found' };
954
+ if (shipment.playerId !== playerId) {
955
+ return { success: false, error: 'Not your shipment' };
956
+ }
957
+ if (shipment.status !== 'in_transit') {
958
+ return { success: false, error: 'Shipment not in transit' };
959
+ }
960
+ const newEscorts = [...shipment.escortIds, unitId];
961
+ await this.db.updateShipment(shipmentId, { escortIds: newEscorts });
962
+ await this.db.updateUnit(unitId, { assignmentId: shipmentId });
963
+ return { success: true };
964
+ }
965
+ async listUnitForSale(playerId, unitId, price) {
966
+ const player = await this.db.getPlayer(playerId);
967
+ if (!player)
968
+ return { success: false, error: 'Player not found' };
969
+ const unit = await this.db.getUnit(unitId);
970
+ if (!unit)
971
+ return { success: false, error: 'Unit not found' };
972
+ if (unit.playerId !== playerId) {
973
+ return { success: false, error: 'Not your unit' };
974
+ }
975
+ if (unit.assignmentId) {
976
+ return { success: false, error: 'Unit is currently assigned. Unassign first.' };
977
+ }
978
+ const zone = await this.db.getZone(unit.locationId);
979
+ if (!zone || zone.type !== 'hub') {
980
+ return { success: false, error: 'Units can only be sold at Hubs' };
981
+ }
982
+ if (price < 1) {
983
+ return { success: false, error: 'Price must be at least 1 credit' };
984
+ }
985
+ await this.db.updateUnit(unitId, { forSalePrice: price });
986
+ return { success: true };
987
+ }
988
+ async unlistUnit(playerId, unitId) {
989
+ const unit = await this.db.getUnit(unitId);
990
+ if (!unit)
991
+ return { success: false, error: 'Unit not found' };
992
+ if (unit.playerId !== playerId) {
993
+ return { success: false, error: 'Not your unit' };
994
+ }
995
+ await this.db.updateUnit(unitId, { forSalePrice: null });
996
+ return { success: true };
997
+ }
998
+ async hireUnit(playerId, unitId) {
999
+ const canAct = await this.canPlayerAct(playerId);
1000
+ if (!canAct.allowed)
1001
+ return { success: false, error: canAct.reason };
1002
+ const player = await this.db.getPlayer(playerId);
1003
+ if (!player)
1004
+ return { success: false, error: 'Player not found' };
1005
+ const unit = await this.db.getUnit(unitId);
1006
+ if (!unit)
1007
+ return { success: false, error: 'Unit not found' };
1008
+ if (unit.forSalePrice === null) {
1009
+ return { success: false, error: 'Unit is not for sale' };
1010
+ }
1011
+ if (unit.playerId === playerId) {
1012
+ return { success: false, error: 'Cannot buy your own unit' };
1013
+ }
1014
+ if (player.locationId !== unit.locationId) {
1015
+ return { success: false, error: 'Must be at the same Hub as the unit' };
1016
+ }
1017
+ const price = unit.forSalePrice;
1018
+ if (player.inventory.credits < price) {
1019
+ return { success: false, error: `Insufficient credits. Need ${price}` };
1020
+ }
1021
+ const seller = await this.db.getPlayer(unit.playerId);
1022
+ if (seller) {
1023
+ const sellerInventory = { ...seller.inventory };
1024
+ sellerInventory.credits += price;
1025
+ await this.db.updatePlayer(seller.id, { inventory: sellerInventory });
1026
+ }
1027
+ const buyerInventory = { ...player.inventory };
1028
+ buyerInventory.credits -= price;
1029
+ await this.db.updatePlayer(playerId, { inventory: buyerInventory });
1030
+ await this.db.updateUnit(unitId, {
1031
+ playerId,
1032
+ forSalePrice: null
1033
+ });
1034
+ await this.recordPlayerAction(playerId);
1035
+ await this.recordEvent('player_action', playerId, 'player', {
1036
+ action: 'hire_unit',
1037
+ unitId,
1038
+ unitType: unit.type,
1039
+ price,
1040
+ sellerId: unit.playerId
1041
+ });
1042
+ const updatedUnit = await this.db.getUnit(unitId);
1043
+ return { success: true, unit: updatedUnit || undefined };
1044
+ }
1045
+ async deployRaider(playerId, unitId, routeId) {
1046
+ const player = await this.db.getPlayer(playerId);
1047
+ if (!player)
1048
+ return { success: false, error: 'Player not found' };
1049
+ const unit = await this.db.getUnit(unitId);
1050
+ if (!unit)
1051
+ return { success: false, error: 'Unit not found' };
1052
+ if (unit.playerId !== playerId) {
1053
+ return { success: false, error: 'Not your unit' };
1054
+ }
1055
+ if (unit.type !== 'raider') {
1056
+ return { success: false, error: 'Only raider units can interdict routes' };
1057
+ }
1058
+ const route = await this.db.getRoute(routeId);
1059
+ if (!route)
1060
+ return { success: false, error: 'Route not found' };
1061
+ await this.db.updateUnit(unitId, { assignmentId: routeId });
1062
+ await this.recordEvent('player_action', playerId, 'player', {
1063
+ action: 'deploy_raider',
1064
+ unitId,
1065
+ routeId
1066
+ });
1067
+ return { success: true };
1068
+ }
1069
+ // ============================================================================
1070
+ // FACTION ACTIONS
1071
+ // ============================================================================
1072
+ async createFaction(playerId, name, tag) {
1073
+ const player = await this.db.getPlayer(playerId);
1074
+ if (!player)
1075
+ return { success: false, error: 'Player not found' };
1076
+ if (player.factionId) {
1077
+ return { success: false, error: 'Already in a faction' };
1078
+ }
1079
+ if (tag.length < 2 || tag.length > 5) {
1080
+ return { success: false, error: 'Tag must be 2-5 characters' };
1081
+ }
1082
+ const faction = await this.db.createFaction(name, tag.toUpperCase(), playerId);
1083
+ return { success: true, faction };
1084
+ }
1085
+ async joinFaction(playerId, factionId) {
1086
+ const player = await this.db.getPlayer(playerId);
1087
+ if (!player)
1088
+ return { success: false, error: 'Player not found' };
1089
+ if (player.factionId) {
1090
+ return { success: false, error: 'Already in a faction' };
1091
+ }
1092
+ const faction = await this.db.getFaction(factionId);
1093
+ if (!faction)
1094
+ return { success: false, error: 'Faction not found' };
1095
+ await this.db.addFactionMember(factionId, playerId);
1096
+ return { success: true };
1097
+ }
1098
+ async leaveFaction(playerId) {
1099
+ const player = await this.db.getPlayer(playerId);
1100
+ if (!player)
1101
+ return { success: false, error: 'Player not found' };
1102
+ if (!player.factionId) {
1103
+ return { success: false, error: 'Not in a faction' };
1104
+ }
1105
+ const faction = await this.db.getFaction(player.factionId);
1106
+ if (faction && faction.founderId === playerId) {
1107
+ return { success: false, error: 'Founder cannot leave. Transfer leadership first.' };
1108
+ }
1109
+ await this.db.removeFactionMember(player.factionId, playerId);
1110
+ return { success: true };
1111
+ }
1112
+ async getFactionIntel(playerId) {
1113
+ const player = await this.db.getPlayer(playerId);
1114
+ if (!player)
1115
+ return { success: false, error: 'Player not found' };
1116
+ if (!player.factionId) {
1117
+ return { success: false, error: 'Not in a faction' };
1118
+ }
1119
+ const intel = await this.db.getFactionIntelWithFreshness(player.factionId);
1120
+ return { success: true, intel };
1121
+ }
1122
+ // ============================================================================
1123
+ // FACTION MANAGEMENT
1124
+ // ============================================================================
1125
+ /**
1126
+ * Promote a faction member to a higher rank
1127
+ */
1128
+ async promoteFactionMember(playerId, targetPlayerId, newRank) {
1129
+ const player = await this.db.getPlayer(playerId);
1130
+ if (!player)
1131
+ return { success: false, error: 'Player not found' };
1132
+ if (!player.factionId) {
1133
+ return { success: false, error: 'Not in a faction' };
1134
+ }
1135
+ const myRank = await this.db.getFactionMemberRank(player.factionId, playerId);
1136
+ if (!myRank)
1137
+ return { success: false, error: 'Could not determine your rank' };
1138
+ const permissions = FACTION_PERMISSIONS[myRank];
1139
+ if (!permissions.canPromote) {
1140
+ return { success: false, error: 'Insufficient permissions to promote members' };
1141
+ }
1142
+ const targetRank = await this.db.getFactionMemberRank(player.factionId, targetPlayerId);
1143
+ if (!targetRank)
1144
+ return { success: false, error: 'Target player not in your faction' };
1145
+ // Can only promote to officer (not founder)
1146
+ if (newRank === 'founder') {
1147
+ return { success: false, error: 'Cannot promote to founder. Transfer leadership instead.' };
1148
+ }
1149
+ // Can only promote from lower rank
1150
+ const rankOrder = { member: 0, officer: 1, founder: 2 };
1151
+ if (rankOrder[newRank] <= rankOrder[targetRank]) {
1152
+ return { success: false, error: 'Can only promote to a higher rank' };
1153
+ }
1154
+ await this.db.updateFactionMemberRank(player.factionId, targetPlayerId, newRank);
1155
+ await this.recordEvent('player_action', playerId, 'player', {
1156
+ action: 'promote_member',
1157
+ targetPlayerId,
1158
+ newRank
1159
+ });
1160
+ return { success: true };
1161
+ }
1162
+ /**
1163
+ * Demote a faction member to a lower rank
1164
+ */
1165
+ async demoteFactionMember(playerId, targetPlayerId, newRank) {
1166
+ const player = await this.db.getPlayer(playerId);
1167
+ if (!player)
1168
+ return { success: false, error: 'Player not found' };
1169
+ if (!player.factionId) {
1170
+ return { success: false, error: 'Not in a faction' };
1171
+ }
1172
+ const myRank = await this.db.getFactionMemberRank(player.factionId, playerId);
1173
+ if (!myRank)
1174
+ return { success: false, error: 'Could not determine your rank' };
1175
+ const permissions = FACTION_PERMISSIONS[myRank];
1176
+ if (!permissions.canDemote) {
1177
+ return { success: false, error: 'Insufficient permissions to demote members' };
1178
+ }
1179
+ const targetRank = await this.db.getFactionMemberRank(player.factionId, targetPlayerId);
1180
+ if (!targetRank)
1181
+ return { success: false, error: 'Target player not in your faction' };
1182
+ if (targetRank === 'founder') {
1183
+ return { success: false, error: 'Cannot demote the founder' };
1184
+ }
1185
+ // Can only demote to a lower rank
1186
+ const rankOrder = { member: 0, officer: 1, founder: 2 };
1187
+ if (rankOrder[newRank] >= rankOrder[targetRank]) {
1188
+ return { success: false, error: 'Can only demote to a lower rank' };
1189
+ }
1190
+ await this.db.updateFactionMemberRank(player.factionId, targetPlayerId, newRank);
1191
+ await this.recordEvent('player_action', playerId, 'player', {
1192
+ action: 'demote_member',
1193
+ targetPlayerId,
1194
+ newRank
1195
+ });
1196
+ return { success: true };
1197
+ }
1198
+ /**
1199
+ * Kick a member from the faction
1200
+ */
1201
+ async kickFactionMember(playerId, targetPlayerId) {
1202
+ const player = await this.db.getPlayer(playerId);
1203
+ if (!player)
1204
+ return { success: false, error: 'Player not found' };
1205
+ if (!player.factionId) {
1206
+ return { success: false, error: 'Not in a faction' };
1207
+ }
1208
+ if (playerId === targetPlayerId) {
1209
+ return { success: false, error: 'Cannot kick yourself. Use leave instead.' };
1210
+ }
1211
+ const myRank = await this.db.getFactionMemberRank(player.factionId, playerId);
1212
+ if (!myRank)
1213
+ return { success: false, error: 'Could not determine your rank' };
1214
+ const permissions = FACTION_PERMISSIONS[myRank];
1215
+ if (!permissions.canKick) {
1216
+ return { success: false, error: 'Insufficient permissions to kick members' };
1217
+ }
1218
+ const targetRank = await this.db.getFactionMemberRank(player.factionId, targetPlayerId);
1219
+ if (!targetRank)
1220
+ return { success: false, error: 'Target player not in your faction' };
1221
+ // Officers can only kick members, not other officers or founder
1222
+ if (myRank === 'officer' && targetRank !== 'member') {
1223
+ return { success: false, error: 'Officers can only kick members' };
1224
+ }
1225
+ if (targetRank === 'founder') {
1226
+ return { success: false, error: 'Cannot kick the founder' };
1227
+ }
1228
+ await this.db.removeFactionMember(player.factionId, targetPlayerId);
1229
+ await this.recordEvent('faction_left', targetPlayerId, 'player', {
1230
+ factionId: player.factionId,
1231
+ reason: 'kicked',
1232
+ kickedBy: playerId
1233
+ });
1234
+ return { success: true };
1235
+ }
1236
+ /**
1237
+ * Transfer faction leadership to another member
1238
+ */
1239
+ async transferFactionLeadership(playerId, targetPlayerId) {
1240
+ const player = await this.db.getPlayer(playerId);
1241
+ if (!player)
1242
+ return { success: false, error: 'Player not found' };
1243
+ if (!player.factionId) {
1244
+ return { success: false, error: 'Not in a faction' };
1245
+ }
1246
+ const faction = await this.db.getFaction(player.factionId);
1247
+ if (!faction)
1248
+ return { success: false, error: 'Faction not found' };
1249
+ if (faction.founderId !== playerId) {
1250
+ return { success: false, error: 'Only the founder can transfer leadership' };
1251
+ }
1252
+ const targetRank = await this.db.getFactionMemberRank(player.factionId, targetPlayerId);
1253
+ if (!targetRank)
1254
+ return { success: false, error: 'Target player not in your faction' };
1255
+ // Update ranks: new leader becomes founder, old leader becomes officer
1256
+ await this.db.updateFactionMemberRank(player.factionId, targetPlayerId, 'founder');
1257
+ await this.db.updateFactionMemberRank(player.factionId, playerId, 'officer');
1258
+ // Update faction founder
1259
+ await this.db.updateFaction(player.factionId, { founderId: targetPlayerId });
1260
+ await this.recordEvent('player_action', playerId, 'player', {
1261
+ action: 'transfer_leadership',
1262
+ targetPlayerId,
1263
+ factionId: player.factionId
1264
+ });
1265
+ return { success: true };
1266
+ }
1267
+ /**
1268
+ * Deposit resources to faction treasury
1269
+ */
1270
+ async depositToTreasury(playerId, resources) {
1271
+ const player = await this.db.getPlayer(playerId);
1272
+ if (!player)
1273
+ return { success: false, error: 'Player not found' };
1274
+ if (!player.factionId) {
1275
+ return { success: false, error: 'Not in a faction' };
1276
+ }
1277
+ const faction = await this.db.getFaction(player.factionId);
1278
+ if (!faction)
1279
+ return { success: false, error: 'Faction not found' };
1280
+ // Verify player has enough resources
1281
+ for (const [resource, amount] of Object.entries(resources)) {
1282
+ if (amount && amount > 0) {
1283
+ if (player.inventory[resource] < amount) {
1284
+ return { success: false, error: `Insufficient ${resource}` };
1285
+ }
1286
+ }
1287
+ }
1288
+ // Transfer resources
1289
+ const newPlayerInventory = { ...player.inventory };
1290
+ const newTreasury = { ...faction.treasury };
1291
+ for (const [resource, amount] of Object.entries(resources)) {
1292
+ if (amount && amount > 0) {
1293
+ newPlayerInventory[resource] -= amount;
1294
+ newTreasury[resource] = (newTreasury[resource] || 0) + amount;
1295
+ }
1296
+ }
1297
+ await this.db.updatePlayer(playerId, { inventory: newPlayerInventory });
1298
+ await this.db.updateFaction(player.factionId, { treasury: newTreasury });
1299
+ await this.recordEvent('player_action', playerId, 'player', {
1300
+ action: 'deposit_treasury',
1301
+ factionId: player.factionId,
1302
+ resources
1303
+ });
1304
+ return { success: true };
1305
+ }
1306
+ /**
1307
+ * Withdraw resources from faction treasury
1308
+ */
1309
+ async withdrawFromTreasury(playerId, resources) {
1310
+ const player = await this.db.getPlayer(playerId);
1311
+ if (!player)
1312
+ return { success: false, error: 'Player not found' };
1313
+ if (!player.factionId) {
1314
+ return { success: false, error: 'Not in a faction' };
1315
+ }
1316
+ const myRank = await this.db.getFactionMemberRank(player.factionId, playerId);
1317
+ if (!myRank)
1318
+ return { success: false, error: 'Could not determine your rank' };
1319
+ const permissions = FACTION_PERMISSIONS[myRank];
1320
+ if (!permissions.canWithdraw) {
1321
+ return { success: false, error: 'Insufficient permissions to withdraw from treasury' };
1322
+ }
1323
+ const faction = await this.db.getFaction(player.factionId);
1324
+ if (!faction)
1325
+ return { success: false, error: 'Faction not found' };
1326
+ // Calculate total credits being withdrawn
1327
+ const totalCredits = resources.credits || 0;
1328
+ // Officers have a withdrawal limit
1329
+ if (myRank === 'officer' && totalCredits > faction.officerWithdrawLimit) {
1330
+ return { success: false, error: `Officers can only withdraw ${faction.officerWithdrawLimit} credits at a time` };
1331
+ }
1332
+ // Verify treasury has enough resources
1333
+ for (const [resource, amount] of Object.entries(resources)) {
1334
+ if (amount && amount > 0) {
1335
+ if (faction.treasury[resource] < amount) {
1336
+ return { success: false, error: `Insufficient ${resource} in treasury` };
1337
+ }
1338
+ }
1339
+ }
1340
+ // Transfer resources
1341
+ const newPlayerInventory = { ...player.inventory };
1342
+ const newTreasury = { ...faction.treasury };
1343
+ for (const [resource, amount] of Object.entries(resources)) {
1344
+ if (amount && amount > 0) {
1345
+ newPlayerInventory[resource] = (newPlayerInventory[resource] || 0) + amount;
1346
+ newTreasury[resource] -= amount;
1347
+ }
1348
+ }
1349
+ await this.db.updatePlayer(playerId, { inventory: newPlayerInventory });
1350
+ await this.db.updateFaction(player.factionId, { treasury: newTreasury });
1351
+ await this.recordEvent('player_action', playerId, 'player', {
1352
+ action: 'withdraw_treasury',
1353
+ factionId: player.factionId,
1354
+ resources
1355
+ });
1356
+ return { success: true };
1357
+ }
1358
+ /**
1359
+ * Get faction details (for members)
1360
+ */
1361
+ async getFactionDetails(playerId) {
1362
+ const player = await this.db.getPlayer(playerId);
1363
+ if (!player)
1364
+ return { success: false, error: 'Player not found' };
1365
+ if (!player.factionId) {
1366
+ return { success: false, error: 'Not in a faction' };
1367
+ }
1368
+ const faction = await this.db.getFaction(player.factionId);
1369
+ if (!faction)
1370
+ return { success: false, error: 'Faction not found' };
1371
+ const myRank = await this.db.getFactionMemberRank(player.factionId, playerId);
1372
+ return { success: true, faction, myRank: myRank || 'member' };
1373
+ }
1374
+ // ============================================================================
1375
+ // LICENSE PROGRESSION
1376
+ // ============================================================================
1377
+ /**
1378
+ * Get available licenses and their requirements
1379
+ */
1380
+ async getLicenseStatus(playerId) {
1381
+ const player = await this.db.getPlayer(playerId);
1382
+ if (!player)
1383
+ return { success: false, error: 'Player not found' };
1384
+ const licenses = ['courier', 'freight', 'convoy'].map(type => {
1385
+ const requirements = LICENSE_REQUIREMENTS[type];
1386
+ const unlocked = player.licenses[type];
1387
+ const canUnlock = !unlocked &&
1388
+ player.reputation >= requirements.reputationRequired &&
1389
+ player.inventory.credits >= requirements.creditsCost;
1390
+ return {
1391
+ type,
1392
+ unlocked,
1393
+ requirements,
1394
+ canUnlock
1395
+ };
1396
+ });
1397
+ return { success: true, licenses };
1398
+ }
1399
+ // ============================================================================
1400
+ // REPUTATION MANAGEMENT
1401
+ // ============================================================================
1402
+ /**
1403
+ * Award reputation to a player (can be positive or negative)
1404
+ */
1405
+ async awardReputation(playerId, amount, reason) {
1406
+ const player = await this.db.getPlayer(playerId);
1407
+ if (!player)
1408
+ return;
1409
+ const newReputation = Math.max(0, Math.min(REPUTATION_REWARDS.maxReputation, player.reputation + amount));
1410
+ await this.db.updatePlayer(playerId, { reputation: newReputation });
1411
+ if (amount !== 0) {
1412
+ await this.recordEvent('player_action', playerId, 'player', {
1413
+ action: 'reputation_change',
1414
+ change: amount,
1415
+ reason,
1416
+ newTotal: newReputation
1417
+ });
1418
+ }
1419
+ }
1420
+ /**
1421
+ * Get player reputation details
1422
+ */
1423
+ // ============================================================================
1424
+ // SEASONS & LEADERBOARDS
1425
+ // ============================================================================
1426
+ /**
1427
+ * Get current season information
1428
+ */
1429
+ async getSeasonStatus() {
1430
+ const tick = await this.db.getCurrentTick();
1431
+ const season = await this.db.getSeasonInfo();
1432
+ const ticksIntoSeason = tick % SEASON_CONFIG.ticksPerSeason;
1433
+ const ticksIntoWeek = tick % SEASON_CONFIG.ticksPerWeek;
1434
+ return {
1435
+ seasonNumber: season.seasonNumber,
1436
+ week: season.seasonWeek,
1437
+ ticksUntilWeekEnd: SEASON_CONFIG.ticksPerWeek - ticksIntoWeek,
1438
+ ticksUntilSeasonEnd: SEASON_CONFIG.ticksPerSeason - ticksIntoSeason
1439
+ };
1440
+ }
1441
+ /**
1442
+ * Get season leaderboard
1443
+ */
1444
+ async getLeaderboard(seasonNumber, entityType, limit = 50) {
1445
+ const season = seasonNumber || (await this.db.getSeasonInfo()).seasonNumber;
1446
+ const leaderboard = await this.db.getSeasonLeaderboard(season, entityType, limit);
1447
+ return { success: true, leaderboard, season };
1448
+ }
1449
+ /**
1450
+ * Get a player's season score
1451
+ */
1452
+ async getPlayerSeasonScore(playerId, seasonNumber) {
1453
+ const player = await this.db.getPlayer(playerId);
1454
+ if (!player)
1455
+ return { success: false, error: 'Player not found' };
1456
+ const season = seasonNumber || (await this.db.getSeasonInfo()).seasonNumber;
1457
+ const score = await this.db.getEntitySeasonScore(season, playerId);
1458
+ // Get rank
1459
+ let rank;
1460
+ if (score) {
1461
+ const leaderboard = await this.db.getSeasonLeaderboard(season, 'player', 1000);
1462
+ const entry = leaderboard.find(e => e.entityId === playerId);
1463
+ rank = entry?.rank;
1464
+ }
1465
+ return { success: true, score, rank };
1466
+ }
1467
+ async getReputationDetails(playerId) {
1468
+ const player = await this.db.getPlayer(playerId);
1469
+ if (!player)
1470
+ return { success: false, error: 'Player not found' };
1471
+ const title = getReputationTitle(player.reputation);
1472
+ // Find next title
1473
+ let nextTitle = null;
1474
+ for (const t of [
1475
+ { threshold: 25, title: 'Runner' },
1476
+ { threshold: 50, title: 'Trader' },
1477
+ { threshold: 100, title: 'Hauler' },
1478
+ { threshold: 200, title: 'Merchant' },
1479
+ { threshold: 350, title: 'Supplier' },
1480
+ { threshold: 500, title: 'Quartermaster' },
1481
+ { threshold: 700, title: 'Logistics Chief' },
1482
+ { threshold: 900, title: 'Supply Marshal' },
1483
+ { threshold: 1000, title: 'Legend' }
1484
+ ]) {
1485
+ if (player.reputation < t.threshold) {
1486
+ nextTitle = {
1487
+ title: t.title,
1488
+ threshold: t.threshold,
1489
+ remaining: t.threshold - player.reputation
1490
+ };
1491
+ break;
1492
+ }
1493
+ }
1494
+ return { success: true, reputation: player.reputation, title, nextTitle };
1495
+ }
1496
+ /**
1497
+ * Unlock a license for a player
1498
+ */
1499
+ async unlockLicense(playerId, licenseType) {
1500
+ const player = await this.db.getPlayer(playerId);
1501
+ if (!player)
1502
+ return { success: false, error: 'Player not found' };
1503
+ if (player.licenses[licenseType]) {
1504
+ return { success: false, error: `${licenseType} license already unlocked` };
1505
+ }
1506
+ const requirements = LICENSE_REQUIREMENTS[licenseType];
1507
+ if (player.reputation < requirements.reputationRequired) {
1508
+ return { success: false, error: `Need ${requirements.reputationRequired} reputation (you have ${player.reputation})` };
1509
+ }
1510
+ if (player.inventory.credits < requirements.creditsCost) {
1511
+ return { success: false, error: `Need ${requirements.creditsCost} credits (you have ${player.inventory.credits})` };
1512
+ }
1513
+ // Deduct cost and unlock license
1514
+ const newInventory = { ...player.inventory };
1515
+ newInventory.credits -= requirements.creditsCost;
1516
+ const newLicenses = { ...player.licenses };
1517
+ newLicenses[licenseType] = true;
1518
+ await this.db.updatePlayer(playerId, {
1519
+ inventory: newInventory,
1520
+ licenses: newLicenses
1521
+ });
1522
+ await this.recordEvent('player_action', playerId, 'player', {
1523
+ action: 'unlock_license',
1524
+ licenseType,
1525
+ cost: requirements.creditsCost
1526
+ });
1527
+ return { success: true };
1528
+ }
1529
+ /**
1530
+ * Get a player's personal intel with freshness decay applied
1531
+ */
1532
+ async getPlayerIntel(playerId, limit = 100) {
1533
+ const player = await this.db.getPlayer(playerId);
1534
+ if (!player)
1535
+ return { success: false, error: 'Player not found' };
1536
+ const intel = await this.db.getPlayerIntelWithFreshness(playerId, limit);
1537
+ return { success: true, intel };
1538
+ }
1539
+ /**
1540
+ * Get the most recent intel on a specific target
1541
+ */
1542
+ async getTargetIntel(playerId, targetType, targetId) {
1543
+ const player = await this.db.getPlayer(playerId);
1544
+ if (!player)
1545
+ return { success: false, error: 'Player not found' };
1546
+ const intel = await this.db.getTargetIntel(playerId, player.factionId, targetType, targetId);
1547
+ return { success: true, intel };
1548
+ }
1549
+ // ============================================================================
1550
+ // CONTRACTS
1551
+ // ============================================================================
1552
+ /**
1553
+ * Create a new contract
1554
+ */
1555
+ async createContract(playerId, type, details, reward, deadline, bonus) {
1556
+ const player = await this.db.getPlayer(playerId);
1557
+ if (!player)
1558
+ return { success: false, error: 'Player not found' };
1559
+ // Check player has enough credits for reward escrow
1560
+ const totalReward = reward + (bonus?.credits || 0);
1561
+ if (player.inventory.credits < totalReward) {
1562
+ return { success: false, error: `Insufficient credits to escrow reward. Need ${totalReward}` };
1563
+ }
1564
+ // Validate contract details based on type
1565
+ if (type === 'haul') {
1566
+ if (!details.fromZoneId || !details.toZoneId || !details.resource || !details.quantity) {
1567
+ return { success: false, error: 'Haul contract requires fromZoneId, toZoneId, resource, and quantity' };
1568
+ }
1569
+ const fromZone = await this.db.getZone(details.fromZoneId);
1570
+ const toZone = await this.db.getZone(details.toZoneId);
1571
+ if (!fromZone)
1572
+ return { success: false, error: 'From zone not found' };
1573
+ if (!toZone)
1574
+ return { success: false, error: 'To zone not found' };
1575
+ }
1576
+ if (type === 'supply') {
1577
+ if (!details.toZoneId || !details.quantity) {
1578
+ return { success: false, error: 'Supply contract requires toZoneId and quantity' };
1579
+ }
1580
+ const zone = await this.db.getZone(details.toZoneId);
1581
+ if (!zone)
1582
+ return { success: false, error: 'Zone not found' };
1583
+ }
1584
+ if (type === 'scout') {
1585
+ if (!details.targetType || !details.targetId) {
1586
+ return { success: false, error: 'Scout contract requires targetType and targetId' };
1587
+ }
1588
+ if (details.targetType === 'zone') {
1589
+ const zone = await this.db.getZone(details.targetId);
1590
+ if (!zone)
1591
+ return { success: false, error: 'Target zone not found' };
1592
+ }
1593
+ else {
1594
+ const route = await this.db.getRoute(details.targetId);
1595
+ if (!route)
1596
+ return { success: false, error: 'Target route not found' };
1597
+ }
1598
+ }
1599
+ // Escrow the reward from player
1600
+ const newInventory = { ...player.inventory };
1601
+ newInventory.credits -= totalReward;
1602
+ await this.db.updatePlayer(playerId, { inventory: newInventory });
1603
+ const tick = await this.db.getCurrentTick();
1604
+ const contract = await this.db.createContract({
1605
+ type,
1606
+ posterId: playerId,
1607
+ posterType: 'player',
1608
+ acceptedBy: null,
1609
+ details,
1610
+ deadline: tick + deadline,
1611
+ reward: { credits: reward, reputation: Math.floor(reward / 10) },
1612
+ bonus: bonus ? { deadline: tick + bonus.deadline, credits: bonus.credits } : undefined,
1613
+ status: 'open',
1614
+ createdAt: tick
1615
+ });
1616
+ await this.recordEvent('contract_posted', playerId, 'player', {
1617
+ contractId: contract.id,
1618
+ type,
1619
+ reward,
1620
+ deadline
1621
+ });
1622
+ return { success: true, contract };
1623
+ }
1624
+ /**
1625
+ * Accept a contract
1626
+ */
1627
+ async acceptContract(playerId, contractId) {
1628
+ const player = await this.db.getPlayer(playerId);
1629
+ if (!player)
1630
+ return { success: false, error: 'Player not found' };
1631
+ const contract = await this.db.getContract(contractId);
1632
+ if (!contract)
1633
+ return { success: false, error: 'Contract not found' };
1634
+ if (contract.status !== 'open') {
1635
+ return { success: false, error: `Contract is ${contract.status}, not open` };
1636
+ }
1637
+ if (contract.posterId === playerId) {
1638
+ return { success: false, error: 'Cannot accept your own contract' };
1639
+ }
1640
+ // Check concurrent contract limit
1641
+ const playerContracts = await this.db.getPlayerContracts(playerId);
1642
+ const activeContracts = playerContracts.filter(c => c.acceptedBy === playerId && c.status === 'active');
1643
+ const limit = TIER_LIMITS[player.tier].concurrentContracts;
1644
+ if (activeContracts.length >= limit) {
1645
+ return { success: false, error: `Contract limit (${limit}) reached` };
1646
+ }
1647
+ await this.db.updateContract(contractId, { status: 'active', acceptedBy: playerId });
1648
+ await this.recordEvent('contract_accepted', playerId, 'player', {
1649
+ contractId,
1650
+ type: contract.type
1651
+ });
1652
+ return { success: true };
1653
+ }
1654
+ /**
1655
+ * Complete a contract (called when conditions are met)
1656
+ */
1657
+ async completeContract(playerId, contractId) {
1658
+ const player = await this.db.getPlayer(playerId);
1659
+ if (!player)
1660
+ return { success: false, error: 'Player not found' };
1661
+ const contract = await this.db.getContract(contractId);
1662
+ if (!contract)
1663
+ return { success: false, error: 'Contract not found' };
1664
+ if (contract.status !== 'active') {
1665
+ return { success: false, error: `Contract is ${contract.status}, not active` };
1666
+ }
1667
+ if (contract.acceptedBy !== playerId) {
1668
+ return { success: false, error: 'You have not accepted this contract' };
1669
+ }
1670
+ const tick = await this.db.getCurrentTick();
1671
+ // Verify contract completion based on type
1672
+ const verifyResult = await this.verifyContractCompletion(player, contract);
1673
+ if (!verifyResult.completed) {
1674
+ return { success: false, error: verifyResult.reason || 'Contract requirements not met' };
1675
+ }
1676
+ // Calculate reward
1677
+ let totalCredits = contract.reward.credits;
1678
+ let totalReputation = contract.reward.reputation;
1679
+ let bonusAwarded = false;
1680
+ if (contract.bonus && tick <= contract.bonus.deadline) {
1681
+ totalCredits += contract.bonus.credits;
1682
+ bonusAwarded = true;
1683
+ }
1684
+ // Pay the player
1685
+ const newInventory = { ...player.inventory };
1686
+ newInventory.credits += totalCredits;
1687
+ await this.db.updatePlayer(playerId, {
1688
+ inventory: newInventory,
1689
+ reputation: player.reputation + totalReputation
1690
+ });
1691
+ await this.db.updateContract(contractId, { status: 'completed' });
1692
+ await this.recordEvent('contract_completed', playerId, 'player', {
1693
+ contractId,
1694
+ type: contract.type,
1695
+ reward: totalCredits,
1696
+ reputation: totalReputation,
1697
+ bonus: bonusAwarded
1698
+ });
1699
+ return {
1700
+ success: true,
1701
+ reward: { credits: totalCredits, reputation: totalReputation },
1702
+ bonus: bonusAwarded
1703
+ };
1704
+ }
1705
+ /**
1706
+ * Cancel a contract (poster only, before acceptance)
1707
+ */
1708
+ async cancelContract(playerId, contractId) {
1709
+ const player = await this.db.getPlayer(playerId);
1710
+ if (!player)
1711
+ return { success: false, error: 'Player not found' };
1712
+ const contract = await this.db.getContract(contractId);
1713
+ if (!contract)
1714
+ return { success: false, error: 'Contract not found' };
1715
+ if (contract.posterId !== playerId) {
1716
+ return { success: false, error: 'Only the poster can cancel' };
1717
+ }
1718
+ if (contract.status !== 'open') {
1719
+ return { success: false, error: 'Can only cancel open contracts' };
1720
+ }
1721
+ // Refund escrowed credits
1722
+ const refund = contract.reward.credits + (contract.bonus?.credits || 0);
1723
+ const newInventory = { ...player.inventory };
1724
+ newInventory.credits += refund;
1725
+ await this.db.updatePlayer(playerId, { inventory: newInventory });
1726
+ await this.db.updateContract(contractId, { status: 'expired' });
1727
+ return { success: true };
1728
+ }
1729
+ /**
1730
+ * Get contracts related to a player
1731
+ */
1732
+ async getMyContracts(playerId) {
1733
+ const player = await this.db.getPlayer(playerId);
1734
+ if (!player)
1735
+ return { success: false, error: 'Player not found' };
1736
+ const contracts = await this.db.getPlayerContracts(playerId);
1737
+ return { success: true, contracts };
1738
+ }
1739
+ /**
1740
+ * Verify if a contract's conditions have been met
1741
+ */
1742
+ async verifyContractCompletion(player, contract) {
1743
+ const tick = await this.db.getCurrentTick();
1744
+ // Check deadline
1745
+ if (tick > contract.deadline) {
1746
+ return { completed: false, reason: 'Contract deadline has passed' };
1747
+ }
1748
+ switch (contract.type) {
1749
+ case 'haul': {
1750
+ // Player must be at destination with the required cargo
1751
+ if (player.locationId !== contract.details.toZoneId) {
1752
+ return { completed: false, reason: `Must be at destination zone` };
1753
+ }
1754
+ const resource = contract.details.resource;
1755
+ const quantity = contract.details.quantity || 0;
1756
+ if (resource && player.inventory[resource] < quantity) {
1757
+ return { completed: false, reason: `Need ${quantity} ${resource} in inventory` };
1758
+ }
1759
+ // Consume the cargo
1760
+ if (resource) {
1761
+ const newInventory = { ...player.inventory };
1762
+ newInventory[resource] -= quantity;
1763
+ await this.db.updatePlayer(player.id, { inventory: newInventory });
1764
+ }
1765
+ return { completed: true };
1766
+ }
1767
+ case 'supply': {
1768
+ // Check if zone has received the required SU since contract acceptance
1769
+ // For simplicity, we check if player is at zone with SU components
1770
+ if (player.locationId !== contract.details.toZoneId) {
1771
+ return { completed: false, reason: `Must be at destination zone` };
1772
+ }
1773
+ // Require player to deposit SU - this is handled by the depositSU action
1774
+ // For now, just verify they're at the zone
1775
+ return { completed: true };
1776
+ }
1777
+ case 'scout': {
1778
+ // Check if fresh intel exists for the target
1779
+ const targetIntel = await this.db.getTargetIntel(player.id, player.factionId, contract.details.targetType, contract.details.targetId);
1780
+ if (!targetIntel) {
1781
+ return { completed: false, reason: 'No intel gathered on target' };
1782
+ }
1783
+ // Intel must have been gathered after contract was accepted
1784
+ if (targetIntel.gatheredAt < contract.createdAt) {
1785
+ return { completed: false, reason: 'Intel was gathered before contract was accepted. Scan again.' };
1786
+ }
1787
+ // Intel must be fresh (not stale or expired)
1788
+ if (targetIntel.freshness !== 'fresh') {
1789
+ return { completed: false, reason: 'Intel is stale. Scan again for fresh intel.' };
1790
+ }
1791
+ return { completed: true };
1792
+ }
1793
+ default:
1794
+ return { completed: false, reason: 'Unknown contract type' };
1795
+ }
1796
+ }
1797
+ // ============================================================================
1798
+ // TRAVEL
1799
+ // ============================================================================
1800
+ async travel(playerId, toZoneId) {
1801
+ const canAct = await this.canPlayerAct(playerId);
1802
+ if (!canAct.allowed)
1803
+ return { success: false, error: canAct.reason };
1804
+ const player = await this.db.getPlayer(playerId);
1805
+ if (!player)
1806
+ return { success: false, error: 'Player not found' };
1807
+ const toZone = await this.db.getZone(toZoneId);
1808
+ if (!toZone)
1809
+ return { success: false, error: 'Destination zone not found' };
1810
+ // Check direct route exists
1811
+ const routes = await this.db.getRoutesBetween(player.locationId, toZoneId);
1812
+ if (routes.length === 0) {
1813
+ return { success: false, error: 'No direct route to destination' };
1814
+ }
1815
+ await this.db.updatePlayer(playerId, { locationId: toZoneId });
1816
+ await this.recordPlayerAction(playerId);
1817
+ return { success: true };
1818
+ }
1819
+ // ============================================================================
1820
+ // UTILITY
1821
+ // ============================================================================
1822
+ async findRoute(fromZoneId, toZoneId) {
1823
+ const routes = await this.db.getRoutesBetween(fromZoneId, toZoneId);
1824
+ return routes.length > 0 ? routes[0] : null;
1825
+ }
1826
+ async recordEvent(type, actorId, actorType, data) {
1827
+ const tick = await this.db.getCurrentTick();
1828
+ return this.db.recordEvent({
1829
+ type,
1830
+ tick,
1831
+ timestamp: new Date(),
1832
+ actorId,
1833
+ actorType,
1834
+ data
1835
+ });
1836
+ }
1837
+ // ============================================================================
1838
+ // PHASE 2: ONBOARDING
1839
+ // ============================================================================
1840
+ async getTutorialStatus(playerId) {
1841
+ const player = await this.db.getPlayer(playerId);
1842
+ if (!player)
1843
+ return { success: false, error: 'Player not found' };
1844
+ return {
1845
+ success: true,
1846
+ step: player.tutorialStep,
1847
+ total: 5,
1848
+ currentContract: player.tutorialStep < 5 ? TUTORIAL_CONTRACTS[player.tutorialStep] : null
1849
+ };
1850
+ }
1851
+ async completeTutorialStep(playerId, step) {
1852
+ const player = await this.db.getPlayer(playerId);
1853
+ if (!player)
1854
+ return { success: false, error: 'Player not found' };
1855
+ if (step !== player.tutorialStep + 1) {
1856
+ return { success: false, error: `Must complete step ${player.tutorialStep + 1} next` };
1857
+ }
1858
+ if (step < 1 || step > 5) {
1859
+ return { success: false, error: 'Invalid tutorial step' };
1860
+ }
1861
+ const contract = TUTORIAL_CONTRACTS[step - 1];
1862
+ const reward = contract.reward;
1863
+ // Award credits and reputation
1864
+ const newInventory = { ...player.inventory };
1865
+ newInventory.credits += reward.credits;
1866
+ await this.db.updatePlayer(playerId, {
1867
+ inventory: newInventory,
1868
+ tutorialStep: step,
1869
+ reputation: player.reputation + reward.reputation
1870
+ });
1871
+ await this.recordEvent('tutorial_completed', playerId, 'player', {
1872
+ step,
1873
+ reward
1874
+ });
1875
+ return { success: true, step, reward };
1876
+ }
1877
+ async ensureStarterFaction() {
1878
+ const factions = await this.db.getAllFactions();
1879
+ const exists = factions.some(f => f.tag === 'FREE');
1880
+ if (!exists) {
1881
+ await this.db.createFaction('Freelancers', 'FREE', 'system');
1882
+ }
1883
+ }
1884
+ // ============================================================================
1885
+ // PHASE 5: DOCTRINES
1886
+ // ============================================================================
1887
+ async createDoctrine(playerId, title, content) {
1888
+ const player = await this.db.getPlayer(playerId);
1889
+ if (!player)
1890
+ return { success: false, error: 'Player not found' };
1891
+ if (!player.factionId) {
1892
+ return { success: false, error: 'Not in a faction' };
1893
+ }
1894
+ const rank = await this.db.getFactionMemberRank(player.factionId, playerId);
1895
+ if (!rank || (rank !== 'officer' && rank !== 'founder')) {
1896
+ return { success: false, error: 'Must be officer or founder to create doctrines' };
1897
+ }
1898
+ const doctrine = await this.db.createDoctrine(player.factionId, title, content, playerId);
1899
+ await this.db.createAuditLog(player.factionId, playerId, 'create_doctrine', {
1900
+ doctrineId: doctrine.id,
1901
+ title
1902
+ });
1903
+ return { success: true, doctrine };
1904
+ }
1905
+ async getFactionDoctrines(playerId) {
1906
+ const player = await this.db.getPlayer(playerId);
1907
+ if (!player)
1908
+ return { success: false, error: 'Player not found' };
1909
+ if (!player.factionId) {
1910
+ return { success: false, error: 'Not in a faction' };
1911
+ }
1912
+ const doctrines = await this.db.getFactionDoctrines(player.factionId);
1913
+ return { success: true, doctrines };
1914
+ }
1915
+ async updateDoctrine(playerId, doctrineId, content) {
1916
+ const player = await this.db.getPlayer(playerId);
1917
+ if (!player)
1918
+ return { success: false, error: 'Player not found' };
1919
+ if (!player.factionId) {
1920
+ return { success: false, error: 'Not in a faction' };
1921
+ }
1922
+ const rank = await this.db.getFactionMemberRank(player.factionId, playerId);
1923
+ if (!rank || (rank !== 'officer' && rank !== 'founder')) {
1924
+ return { success: false, error: 'Must be officer or founder to update doctrines' };
1925
+ }
1926
+ await this.db.updateDoctrine(doctrineId, content);
1927
+ await this.db.createAuditLog(player.factionId, playerId, 'update_doctrine', {
1928
+ doctrineId
1929
+ });
1930
+ return { success: true };
1931
+ }
1932
+ async deleteDoctrine(playerId, doctrineId) {
1933
+ const player = await this.db.getPlayer(playerId);
1934
+ if (!player)
1935
+ return { success: false, error: 'Player not found' };
1936
+ if (!player.factionId) {
1937
+ return { success: false, error: 'Not in a faction' };
1938
+ }
1939
+ const rank = await this.db.getFactionMemberRank(player.factionId, playerId);
1940
+ if (!rank || (rank !== 'officer' && rank !== 'founder')) {
1941
+ return { success: false, error: 'Must be officer or founder to delete doctrines' };
1942
+ }
1943
+ await this.db.deleteDoctrine(doctrineId);
1944
+ await this.db.createAuditLog(player.factionId, playerId, 'delete_doctrine', {
1945
+ doctrineId
1946
+ });
1947
+ return { success: true };
1948
+ }
1949
+ // ============================================================================
1950
+ // PHASE 5: ADVANCED MARKET ORDERS
1951
+ // ============================================================================
1952
+ async createConditionalOrder(playerId, zoneId, resource, side, triggerPrice, quantity, condition) {
1953
+ const player = await this.db.getPlayer(playerId);
1954
+ if (!player)
1955
+ return { success: false, error: 'Player not found' };
1956
+ if (player.tier !== 'operator' && player.tier !== 'command') {
1957
+ return { success: false, error: 'Conditional orders require Operator or Command tier' };
1958
+ }
1959
+ if (player.locationId !== zoneId) {
1960
+ return { success: false, error: 'Must be at the zone' };
1961
+ }
1962
+ const tick = await this.db.getCurrentTick();
1963
+ const order = await this.db.createConditionalOrder({
1964
+ playerId,
1965
+ zoneId,
1966
+ resource: resource,
1967
+ side: side,
1968
+ triggerPrice,
1969
+ quantity,
1970
+ condition: condition,
1971
+ status: 'active',
1972
+ createdAt: tick
1973
+ });
1974
+ return { success: true, order };
1975
+ }
1976
+ async createTimeWeightedOrder(playerId, zoneId, resource, side, price, totalQuantity, quantityPerTick) {
1977
+ const player = await this.db.getPlayer(playerId);
1978
+ if (!player)
1979
+ return { success: false, error: 'Player not found' };
1980
+ if (player.tier !== 'command') {
1981
+ return { success: false, error: 'Time-weighted orders require Command tier' };
1982
+ }
1983
+ if (player.locationId !== zoneId) {
1984
+ return { success: false, error: 'Must be at the zone' };
1985
+ }
1986
+ const tick = await this.db.getCurrentTick();
1987
+ const order = await this.db.createTimeWeightedOrder({
1988
+ playerId,
1989
+ zoneId,
1990
+ resource: resource,
1991
+ side: side,
1992
+ price,
1993
+ totalQuantity,
1994
+ remainingQuantity: totalQuantity,
1995
+ quantityPerTick,
1996
+ status: 'active',
1997
+ createdAt: tick
1998
+ });
1999
+ return { success: true, order };
2000
+ }
2001
+ async processConditionalOrders(tick) {
2002
+ const orders = await this.db.getActiveConditionalOrders();
2003
+ for (const order of orders) {
2004
+ const marketOrders = await this.db.getOrdersForZone(order.zoneId, order.resource);
2005
+ let bestPrice = null;
2006
+ if (order.side === 'buy') {
2007
+ // Looking at sell orders for best ask price
2008
+ const sells = marketOrders.filter(o => o.side === 'sell' && o.quantity > 0);
2009
+ if (sells.length > 0) {
2010
+ bestPrice = Math.min(...sells.map(o => o.price));
2011
+ }
2012
+ }
2013
+ else {
2014
+ // Looking at buy orders for best bid price
2015
+ const buys = marketOrders.filter(o => o.side === 'buy' && o.quantity > 0);
2016
+ if (buys.length > 0) {
2017
+ bestPrice = Math.max(...buys.map(o => o.price));
2018
+ }
2019
+ }
2020
+ if (bestPrice === null)
2021
+ continue;
2022
+ let conditionMet = false;
2023
+ if (order.condition === 'price_below' && bestPrice <= order.triggerPrice) {
2024
+ conditionMet = true;
2025
+ }
2026
+ else if (order.condition === 'price_above' && bestPrice >= order.triggerPrice) {
2027
+ conditionMet = true;
2028
+ }
2029
+ if (conditionMet) {
2030
+ await this.db.createOrder({
2031
+ playerId: order.playerId,
2032
+ zoneId: order.zoneId,
2033
+ resource: order.resource,
2034
+ side: order.side,
2035
+ price: order.triggerPrice,
2036
+ quantity: order.quantity,
2037
+ originalQuantity: order.quantity,
2038
+ createdAt: tick
2039
+ });
2040
+ await this.db.updateConditionalOrderStatus(order.id, 'triggered');
2041
+ }
2042
+ }
2043
+ }
2044
+ async processTimeWeightedOrders(tick) {
2045
+ const orders = await this.db.getActiveTimeWeightedOrders();
2046
+ for (const order of orders) {
2047
+ const qty = Math.min(order.quantityPerTick, order.remainingQuantity);
2048
+ if (qty <= 0)
2049
+ continue;
2050
+ await this.db.createOrder({
2051
+ playerId: order.playerId,
2052
+ zoneId: order.zoneId,
2053
+ resource: order.resource,
2054
+ side: order.side,
2055
+ price: order.price,
2056
+ quantity: qty,
2057
+ originalQuantity: qty,
2058
+ createdAt: tick
2059
+ });
2060
+ const newRemaining = order.remainingQuantity - qty;
2061
+ await this.db.updateTimeWeightedOrder(order.id, newRemaining);
2062
+ if (newRemaining <= 0) {
2063
+ await this.db.updateTimeWeightedOrder(order.id, 0);
2064
+ }
2065
+ }
2066
+ }
2067
+ async processAdvancedOrders(tick) {
2068
+ const events = [];
2069
+ await this.processConditionalOrders(tick);
2070
+ await this.processTimeWeightedOrders(tick);
2071
+ return events;
2072
+ }
2073
+ // ============================================================================
2074
+ // PHASE 6: WEBHOOKS
2075
+ // ============================================================================
2076
+ async registerWebhook(playerId, url, events) {
2077
+ const player = await this.db.getPlayer(playerId);
2078
+ if (!player)
2079
+ return { success: false, error: 'Player not found' };
2080
+ if (player.tier !== 'operator' && player.tier !== 'command') {
2081
+ return { success: false, error: 'Webhooks require Operator or Command tier' };
2082
+ }
2083
+ if (!url.startsWith('https://')) {
2084
+ return { success: false, error: 'Webhook URL must start with https://' };
2085
+ }
2086
+ const existing = await this.db.getPlayerWebhooks(playerId);
2087
+ if (existing.length >= 5) {
2088
+ return { success: false, error: 'Maximum 5 webhooks per player' };
2089
+ }
2090
+ const webhook = await this.db.createWebhook(playerId, url, events);
2091
+ return { success: true, webhook };
2092
+ }
2093
+ async getWebhooks(playerId) {
2094
+ const player = await this.db.getPlayer(playerId);
2095
+ if (!player)
2096
+ return { success: false, error: 'Player not found' };
2097
+ const webhooks = await this.db.getPlayerWebhooks(playerId);
2098
+ return { success: true, webhooks };
2099
+ }
2100
+ async deleteWebhook(playerId, webhookId) {
2101
+ const player = await this.db.getPlayer(playerId);
2102
+ if (!player)
2103
+ return { success: false, error: 'Player not found' };
2104
+ await this.db.deleteWebhook(webhookId, playerId);
2105
+ return { success: true };
2106
+ }
2107
+ async triggerWebhooks(eventType, data) {
2108
+ const webhooks = await this.db.getWebhooksForEvent(eventType);
2109
+ for (const webhook of webhooks) {
2110
+ if (!webhook.active)
2111
+ continue;
2112
+ try {
2113
+ const response = await fetch(webhook.url, {
2114
+ method: 'POST',
2115
+ headers: { 'Content-Type': 'application/json' },
2116
+ body: JSON.stringify({ event: eventType, data, timestamp: new Date().toISOString() })
2117
+ });
2118
+ if (response.ok) {
2119
+ const tick = await this.db.getCurrentTick();
2120
+ await this.db.updateWebhookStatus(webhook.id, tick, 0);
2121
+ }
2122
+ else {
2123
+ const newFailCount = webhook.failCount + 1;
2124
+ const tick = await this.db.getCurrentTick();
2125
+ await this.db.updateWebhookStatus(webhook.id, tick, newFailCount);
2126
+ if (newFailCount >= 5) {
2127
+ await this.db.updateWebhookStatus(webhook.id, tick, newFailCount);
2128
+ }
2129
+ }
2130
+ }
2131
+ catch {
2132
+ const newFailCount = webhook.failCount + 1;
2133
+ const tick = await this.db.getCurrentTick();
2134
+ await this.db.updateWebhookStatus(webhook.id, tick, newFailCount);
2135
+ }
2136
+ }
2137
+ }
2138
+ // ============================================================================
2139
+ // PHASE 6: DATA EXPORT
2140
+ // ============================================================================
2141
+ async exportPlayerData(playerId) {
2142
+ const player = await this.db.getPlayer(playerId);
2143
+ if (!player)
2144
+ return { success: false, error: 'Player not found' };
2145
+ const units = await this.db.getPlayerUnits(playerId);
2146
+ const shipments = await this.db.getPlayerShipments(playerId);
2147
+ const contracts = await this.db.getPlayerContracts(playerId);
2148
+ const intel = await this.db.getPlayerIntelWithFreshness(playerId);
2149
+ const events = await this.db.getEvents({ actorId: playerId });
2150
+ return {
2151
+ success: true,
2152
+ data: { player, units, shipments, contracts, intel, events }
2153
+ };
2154
+ }
2155
+ // ============================================================================
2156
+ // PHASE 6: BATCH OPERATIONS
2157
+ // ============================================================================
2158
+ async executeBatch(playerId, operations) {
2159
+ if (operations.length > 10) {
2160
+ return { success: false, error: 'Maximum 10 operations per batch' };
2161
+ }
2162
+ const player = await this.db.getPlayer(playerId);
2163
+ if (!player)
2164
+ return { success: false, error: 'Player not found' };
2165
+ const results = [];
2166
+ for (const op of operations) {
2167
+ const canAct = await this.canPlayerAct(playerId);
2168
+ if (!canAct.allowed) {
2169
+ results.push({ action: op.action, result: { success: false, error: canAct.reason } });
2170
+ continue;
2171
+ }
2172
+ let result;
2173
+ try {
2174
+ switch (op.action) {
2175
+ case 'travel':
2176
+ result = await this.travel(playerId, op.params.toZoneId);
2177
+ break;
2178
+ case 'extract':
2179
+ result = await this.extract(playerId, op.params.quantity);
2180
+ break;
2181
+ case 'produce':
2182
+ result = await this.produce(playerId, op.params.output, op.params.quantity);
2183
+ break;
2184
+ case 'placeOrder':
2185
+ result = await this.placeOrder(playerId, op.params.zoneId, op.params.resource, op.params.side, op.params.price, op.params.quantity);
2186
+ break;
2187
+ case 'depositSU':
2188
+ result = await this.depositSU(playerId, op.params.zoneId, op.params.amount);
2189
+ break;
2190
+ case 'scan':
2191
+ result = await this.scan(playerId, op.params.targetType, op.params.targetId);
2192
+ break;
2193
+ default:
2194
+ result = { success: false, error: `Unknown action: ${op.action}` };
2195
+ }
2196
+ }
2197
+ catch (err) {
2198
+ result = { success: false, error: err.message || 'Action failed' };
2199
+ }
2200
+ results.push({ action: op.action, result });
2201
+ }
2202
+ return { success: true, results };
2203
+ }
2204
+ // ============================================================================
2205
+ // PHASE 7: FACTION ANALYTICS
2206
+ // ============================================================================
2207
+ async getFactionAnalytics(playerId) {
2208
+ const player = await this.db.getPlayer(playerId);
2209
+ if (!player)
2210
+ return { success: false, error: 'Player not found' };
2211
+ if (player.tier !== 'operator' && player.tier !== 'command') {
2212
+ return { success: false, error: 'Faction analytics require Operator or Command tier' };
2213
+ }
2214
+ if (!player.factionId) {
2215
+ return { success: false, error: 'Not in a faction' };
2216
+ }
2217
+ const rank = await this.db.getFactionMemberRank(player.factionId, playerId);
2218
+ if (!rank || (rank !== 'officer' && rank !== 'founder')) {
2219
+ return { success: false, error: 'Must be officer or founder to view analytics' };
2220
+ }
2221
+ // Get members with activity data
2222
+ const faction = await this.db.getFaction(player.factionId);
2223
+ if (!faction)
2224
+ return { success: false, error: 'Faction not found' };
2225
+ const allPlayers = await this.db.getAllPlayers();
2226
+ const members = allPlayers
2227
+ .filter(p => p.factionId === player.factionId)
2228
+ .map(p => ({
2229
+ id: p.id,
2230
+ name: p.name,
2231
+ tier: p.tier,
2232
+ lastActionTick: p.lastActionTick,
2233
+ reputation: p.reputation,
2234
+ locationId: p.locationId
2235
+ }));
2236
+ // Get zone control summary
2237
+ const allZones = await this.db.getAllZones();
2238
+ const zones = allZones
2239
+ .filter(z => z.ownerId === player.factionId)
2240
+ .map(z => ({
2241
+ id: z.id,
2242
+ name: z.name,
2243
+ type: z.type,
2244
+ supplyLevel: z.supplyLevel,
2245
+ suStockpile: z.suStockpile,
2246
+ burnRate: z.burnRate
2247
+ }));
2248
+ // Get resource flow from audit logs
2249
+ const auditLogs = await this.db.getFactionAuditLogs(player.factionId, 100);
2250
+ const activity = auditLogs.map(log => ({
2251
+ action: log.action,
2252
+ playerId: log.playerId,
2253
+ details: log.details,
2254
+ tick: log.tick
2255
+ }));
2256
+ return {
2257
+ success: true,
2258
+ analytics: { members, zones, activity }
2259
+ };
2260
+ }
2261
+ async getFactionAuditLogs(playerId, limit) {
2262
+ const player = await this.db.getPlayer(playerId);
2263
+ if (!player)
2264
+ return { success: false, error: 'Player not found' };
2265
+ if (player.tier !== 'command') {
2266
+ return { success: false, error: 'Audit logs require Command tier' };
2267
+ }
2268
+ if (!player.factionId) {
2269
+ return { success: false, error: 'Not in a faction' };
2270
+ }
2271
+ const logs = await this.db.getFactionAuditLogs(player.factionId, limit || 100);
2272
+ return { success: true, logs };
2273
+ }
2274
+ }