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