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,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
|
+
}
|