Scrutinizer GitHub App not installed

We could not synchronize checks via GitHub's checks API since Scrutinizer's GitHub App is not installed for this repository.

Install GitHub App

Issues (412)

src/tools/npc/npc.php (4 issues)

1
<?php declare(strict_types=1);
2
3
use Smr\Container\DiContainer;
4
use Smr\Database;
5
use Smr\Epoch;
6
use Smr\Npc\Exceptions\FinalAction;
7
use Smr\Npc\Exceptions\ForwardAction;
8
use Smr\Npc\Exceptions\TradeRouteDrained;
9
use Smr\Npc\NpcActor;
10
use Smr\Page\Page;
11
use Smr\Pages\Account\ErrorDisplay;
12
use Smr\Pages\Player\CargoDumpProcessor;
13
use Smr\Pages\Player\CurrentSector;
14
use Smr\Pages\Player\PlotCourseConventionalProcessor;
15
use Smr\Pages\Player\PlotCourseNearestProcessor;
16
use Smr\Pages\Player\SectorMoveProcessor;
17
use Smr\Pages\Player\ShopGoodsProcessor;
18
use Smr\Race;
19
use Smr\Routes\RouteGenerator;
20
use Smr\SectorLock;
21
use Smr\TradeGood;
22
use Smr\TransactionType;
23
24
function overrideForward(Page $container): never {
25
	global $forwardedContainer;
26
	$forwardedContainer = $container;
27
	if ($container instanceof ErrorDisplay) {
28
		// We hit a create_error - this shouldn't happen for an NPC often,
29
		// for now we want to throw an exception for it for testing.
30
		debug('Hit an error');
31
		throw new Exception($container->message);
32
	}
33
	// We have to throw the exception to get back up the stack,
34
	// otherwise we quickly hit problems of overflowing the stack.
35
	throw new ForwardAction();
36
}
37
const OVERRIDE_FORWARD = true;
38
39
// global config
40
require_once(realpath(dirname(__FILE__)) . '/../../bootstrap.php');
41
42
// Enable NPC-specific conditions
43
DiContainer::getContainer()->set('NPC_SCRIPT', true);
44
45
// Raise exceptions for all types of errors for improved error reporting
46
// and to attempt to shut down the NPCs cleanly on errors.
47
set_error_handler('exception_error_handler');
48
49
const SHIP_UPGRADE_PATH = [
50
	RACE_ALSKANT => [
51
		SHIP_TYPE_TRADE_MASTER,
52
		SHIP_TYPE_DEEP_SPACER,
53
		SHIP_TYPE_DEAL_MAKER,
54
		SHIP_TYPE_TRIP_MAKER,
55
		SHIP_TYPE_SMALL_TIMER,
56
	],
57
	RACE_CREONTI => [
58
		SHIP_TYPE_DEVASTATOR,
59
		SHIP_TYPE_JUGGERNAUT,
60
		SHIP_TYPE_GOLIATH,
61
		SHIP_TYPE_LEVIATHAN,
62
		SHIP_TYPE_MEDIUM_CARGO_HULK,
63
	],
64
	RACE_HUMAN => [
65
		SHIP_TYPE_DESTROYER,
66
		SHIP_TYPE_BORDER_CRUISER,
67
		SHIP_TYPE_AMBASSADOR,
68
		SHIP_TYPE_RENAISSANCE,
69
		SHIP_TYPE_LIGHT_FREIGHTER,
70
	],
71
	RACE_IKTHORNE => [
72
		SHIP_TYPE_MOTHER_SHIP,
73
		SHIP_TYPE_ADVANCED_CARRIER,
74
		SHIP_TYPE_FAVOURED_OFFSPRING,
75
		SHIP_TYPE_PROTO_CARRIER,
76
		SHIP_TYPE_TINY_DELIGHT,
77
	],
78
	RACE_SALVENE => [
79
		SHIP_TYPE_EATER_OF_SOULS,
80
		SHIP_TYPE_RAVAGER,
81
		SHIP_TYPE_PREDATOR,
82
		SHIP_TYPE_DRUDGE,
83
		SHIP_TYPE_HATCHLINGS_DUE,
84
	],
85
	RACE_THEVIAN => [
86
		SHIP_TYPE_ASSAULT_CRAFT,
87
		SHIP_TYPE_CARAPACE,
88
		SHIP_TYPE_BOUNTY_HUNTER,
89
		SHIP_TYPE_EXPEDITER,
90
		SHIP_TYPE_SWIFT_VENTURE,
91
	],
92
	RACE_WQHUMAN => [
93
		SHIP_TYPE_DARK_MIRAGE,
94
		SHIP_TYPE_BLOCKADE_RUNNER,
95
		SHIP_TYPE_ROGUE,
96
		SHIP_TYPE_RESISTANCE,
97
		SHIP_TYPE_SLIP_FREIGHTER,
98
	],
99
	RACE_NIJARIN => [
100
		SHIP_TYPE_FURY,
101
		SHIP_TYPE_VINDICATOR,
102
		SHIP_TYPE_VENGEANCE,
103
		SHIP_TYPE_RETALIATION,
104
		SHIP_TYPE_REDEEMER,
105
	],
106
];
107
108
109
try {
110
	while (npcDriver() === false) {
111
		// No actions taken, try another NPC
112
	}
113
} catch (Throwable $e) {
114
	logException($e);
0 ignored issues
show
The function logException was not found. Maybe you did not declare it correctly or list all dependencies? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

114
	/** @scrutinizer ignore-call */ 
115
 logException($e);
Loading history...
115
}
116
// Try to shut down cleanly
117
exitNPC();
118
119
120
/**
121
 * @return bool If the NPC performed any actions
122
 */
123
function npcDriver(): bool {
124
	global $previousContainer;
125
126
	$session = Smr\Session::getInstance();
127
	$session->setCurrentVar(new Page()); // initialize fake var
128
129
	// Load the first available NPC
130
	changeNPCLogin();
131
132
	// We chose a new NPC, we don't care what we were doing beforehand.
133
	$previousContainer = null;
134
135
	try {
136
		$actor = new NpcActor($session->getGameID(), $session->getAccountID());
137
	} catch (FinalAction) {
138
		// Startup conditions not satisfied, try another NPC
139
		return false;
140
	}
141
142
	// Loop over actions for this NPC
143
	while (true) {
144
		try {
145
			$container = $actor->getNextAction();
146
			processContainer($container);
147
		} catch (ForwardAction) {
148
			// we took an action
149
		} catch (FinalAction) {
150
			$actor->shutdown();
151
			// switch to a new NPC if we haven't taken any actions yet
152
			return $actor->getNumActions() > 0;
153
		} finally {
154
			// Save any changes that we made during this action
155
			saveAllAndReleaseLock(updateSession: false);
156
157
			//Clean up the caches as the data may get changed by other players
158
			clearCaches();
159
		}
160
161
		//Have a sleep between actions
162
		sleepNPC();
163
	}
164
}
165
166
function clearCaches(): void {
167
	SmrSector::clearCache();
168
	SmrPlayer::clearCache();
169
	SmrShip::clearCache();
170
	SmrForce::clearCache();
171
	SmrPort::clearCache();
172
}
173
174
function debug(string $message, mixed $debugObject = null): void {
175
	echo date('Y-m-d H:i:s - ') . $message . ($debugObject !== null ? EOL . var_export($debugObject, true) : '') . EOL;
176
	if (NPC_LOG_TO_DATABASE) {
177
		$session = Smr\Session::getInstance();
178
		$accountID = $session->getAccountID();
179
		$var = $session->getCurrentVar();
180
		$db = Database::getInstance();
181
		$logID = $db->insert('npc_logs', [
182
			'script_id' => $db->escapeNumber(defined('SCRIPT_ID') ? SCRIPT_ID : 0),
183
			'npc_id' => $db->escapeNumber($accountID),
184
			'time' => 'NOW()',
185
			'message' => $db->escapeString($message),
186
			'debug_info' => $db->escapeString(var_export($debugObject, true)),
187
			'var' => $db->escapeString(var_export($var, true)),
188
		]);
189
190
		// On the first call to debug, we need to update the script_id retroactively
191
		if (!defined('SCRIPT_ID')) {
192
			define('SCRIPT_ID', $logID);
193
			$db->write('UPDATE npc_logs SET script_id=' . SCRIPT_ID . ' WHERE log_id=' . SCRIPT_ID);
194
		}
195
	}
196
}
197
198
/**
199
 * Determines if a player has enough turns to start taking actions
200
 */
201
function checkStartConditions(AbstractSmrPlayer $player): void {
202
	$minTurnsThreshold = rand($player->getMaxTurns() / 2, $player->getMaxTurns());
203
	if ($player->getTurns() < $minTurnsThreshold && !$player->canFight()) {
204
		debug('We don\'t have enough turns to bother starting trading, and we are protected: ' . $player->getTurns());
205
		throw new FinalAction();
206
	}
207
}
208
209
function processContainer(Page $container): never {
210
	global $forwardedContainer, $previousContainer;
211
	$session = Smr\Session::getInstance();
212
	$player = $session->getPlayer();
213
	if ($container == $previousContainer && $forwardedContainer->file != 'forces_attack.php') {
214
		debug('We are executing the same container twice?', ['ForwardedContainer' => $forwardedContainer, 'Container' => $container]);
215
		if (!$player->canFight()) {
216
			// Only throw the exception if we have protection, otherwise let's hope that the NPC will be able to find its way to safety rather than dying in the open.
217
			throw new Exception('We are executing the same container twice?');
218
		}
219
	}
220
	clearCaches(); //Clear caches of anything we have used for decision making before processing container and getting lock.
221
	$previousContainer = $container;
222
	debug('Executing container', $container);
223
	// The next "page request" must occur at an updated time.
224
	Epoch::update();
225
	$session->setCurrentVar($container);
226
227
	SectorLock::getInstance()->acquireForPlayer($player);
228
	$container->process();
229
	throw new Exception('Container did not forward as expected!');
230
}
231
232
function sleepNPC(): void {
233
	usleep(rand(NPC_MIN_SLEEP_TIME, NPC_MAX_SLEEP_TIME)); //Sleep for a random time
234
}
235
236
// Releases an NPC when it is done working
237
function releaseNPC(): void {
238
	$session = Smr\Session::getInstance();
239
	if (!$session->hasAccount()) {
240
		debug('releaseNPC: no NPC to release');
241
		return;
242
	}
243
	$login = $session->getAccount()->getLogin();
244
	$db = Database::getInstance();
245
	$db->write('UPDATE npc_logins SET working=' . $db->escapeBoolean(false) . ' WHERE login=' . $db->escapeString($login));
246
	if ($db->getChangedRows() > 0) {
247
		debug('Released NPC: ' . $login);
248
	} else {
249
		debug('Failed to release NPC: ' . $login);
250
	}
251
252
	// Delete sector lock associated with this NPC
253
	SectorLock::resetInstance();
254
}
255
256
function exitNPC(): void {
257
	debug('Exiting NPC script.');
258
	releaseNPC();
259
	exit;
0 ignored issues
show
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
260
}
261
262
function changeNPCLogin(): void {
263
	// Release previous NPC, if any
264
	releaseNPC();
265
266
	// Lacking a convenient way to get up-to-date turns, order NPCs by how
267
	// recently they have taken an action.
268
	debug('Choosing new NPC');
269
	static $availableNpcs = null;
270
271
	$db = Database::getInstance();
272
	$session = Smr\Session::getInstance();
273
274
	if ($availableNpcs === null) {
275
		// Make sure NPC's have been set up in the database
276
		$dbResult = $db->read('SELECT 1 FROM npc_logins LIMIT 1');
277
		if (!$dbResult->hasRecord()) {
278
			debug('No NPCs have been created yet!');
279
			exitNPC();
280
		}
281
282
		// Make sure to select NPCs from active games only
283
		$dbResult = $db->read('SELECT account_id, game_id FROM player JOIN account USING(account_id) JOIN npc_logins USING(login) JOIN game USING(game_id) WHERE active=\'TRUE\' AND working=\'FALSE\' AND start_time < ' . $db->escapeNumber(Epoch::time()) . ' AND end_time > ' . $db->escapeNumber(Epoch::time()) . ' ORDER BY last_turn_update ASC');
284
		foreach ($dbResult->records() as $dbRecord) {
285
			$availableNpcs[] = [
286
				'account_id' => $dbRecord->getInt('account_id'),
287
				'game_id' => $dbRecord->getInt('game_id'),
288
			];
289
		}
290
	}
291
292
	if (empty($availableNpcs)) {
293
		debug('No free NPCs');
294
		exitNPC();
295
	}
296
297
	// Pop an NPC off the top of the stack to activate
298
	$npc = array_shift($availableNpcs);
299
300
	// Update session info for this chosen NPC
301
	$account = SmrAccount::getAccount($npc['account_id']);
302
	$session->setAccount($account);
303
	$session->updateGame($npc['game_id']);
304
305
	$db->write('UPDATE npc_logins SET working=' . $db->escapeBoolean(true) . ' WHERE login=' . $db->escapeString($account->getLogin()));
306
	debug('Chosen NPC: login = ' . $account->getLogin() . ', game = ' . $session->getGameID() . ', player = ' . $session->getPlayer()->getPlayerName());
307
}
308
309
function tradeGoods(int $goodID, AbstractSmrPlayer $player, SmrPort $port): Page {
310
	sleepNPC(); //We have an extra sleep at port to make the NPC more vulnerable.
311
	$ship = $player->getShip();
312
	$relations = $player->getRelation($port->getRaceID());
313
314
	$transaction = $port->getGoodTransaction($goodID);
315
316
	if ($transaction === TransactionType::Buy) {
317
		debug('Buy Goods');
318
		$amount = $ship->getEmptyHolds();
319
	} else {
320
		debug('Sell Goods');
321
		$amount = $ship->getCargo($goodID);
322
	}
323
324
	if ($port->getGoodAmount($goodID) < $amount) {
325
		throw new TradeRouteDrained();
326
	}
327
328
	$idealPrice = $port->getIdealPrice($goodID, $transaction, $amount, $relations);
329
	$offeredPrice = $port->getOfferPrice($idealPrice, $relations, $transaction);
330
331
	return new ShopGoodsProcessor(
332
		goodID: $goodID,
333
		amount: $amount,
334
		bargainNumber: 1,
335
		bargainPrice: $offeredPrice // take the offered price
336
	);
337
}
338
339
function dumpCargo(AbstractSmrPlayer $player): Page {
340
	$ship = $player->getShip();
341
	$cargo = $ship->getCargo();
342
	debug('Ship Cargo', $cargo);
343
	foreach ($cargo as $goodID => $amount) {
344
		if ($amount > 0) {
345
			return new CargoDumpProcessor($goodID, $amount);
346
		}
347
	}
348
	throw new Exception('Called dumpCargo without any cargo!');
349
}
350
351
function plotToSector(AbstractSmrPlayer $player, int $sectorID): Page {
352
	return new PlotCourseConventionalProcessor(from: $player->getSectorID(), to: $sectorID);
353
}
354
355
function plotToFed(AbstractSmrPlayer $player): Page {
356
	debug('Plotting To Fed');
357
358
	// Always drop illegal goods before heading to fed space
359
	if ($player->getShip()->hasIllegalGoods()) {
360
		debug('Dumping illegal goods');
361
		processContainer(dumpCargo($player));
362
	}
363
364
	$fedLocID = $player->getRaceID() + LOCATION_GROUP_RACIAL_BEACONS;
365
	$container = plotToNearest($player, SmrLocation::getLocation($player->getGameID(), $fedLocID));
366
	if ($container === false) {
0 ignored issues
show
The condition $container === false is always false.
Loading history...
367
		debug('Plotted to fed whilst in fed, switch NPC and wait for turns');
368
		throw new FinalAction();
369
	}
370
	return $container;
371
}
372
373
function plotToNearest(AbstractSmrPlayer $player, mixed $realX): Page|false {
374
	debug('Plotting To: ', $realX); //TODO: Can we make the debug output a bit nicer?
375
376
	if ($player->getSector()->hasX($realX)) { //Check if current sector has what we're looking for before we attempt to plot and get error.
377
		debug('Already available in sector');
378
		return false;
379
	}
380
381
	return new PlotCourseNearestProcessor($realX);
382
}
383
384
function moveToSector(AbstractSmrPlayer $player, int $targetSector): Page {
385
	debug('Moving from #' . $player->getSectorID() . ' to #' . $targetSector);
386
	return new SectorMoveProcessor($targetSector, new CurrentSector());
387
}
388
389
function checkForShipUpgrade(AbstractSmrPlayer $player): void {
390
	foreach (SHIP_UPGRADE_PATH[$player->getRaceID()] as $upgradeShipID) {
391
		if ($player->getShipTypeID() == $upgradeShipID) {
392
			//We can't upgrade, only downgrade.
393
			return;
394
		}
395
		$cost = $player->getShip()->getCostToUpgrade($upgradeShipID);
396
		$balance = $player->getCredits() - $cost;
397
		if ($balance > NPC_MINIMUM_RESERVE_CREDITS) {
398
			debug('Upgrading to ship type: ' . $upgradeShipID);
399
			$player->setCredits($balance);
400
			$player->getShip()->setTypeID($upgradeShipID);
401
			return;
402
		}
403
	}
404
}
405
406
function setupShip(AbstractSmrPlayer $player): void {
407
	// Upgrade ships if we can
408
	checkForShipUpgrade($player);
409
410
	// Start the NPC with max hardware
411
	$ship = $player->getShip();
412
	$ship->setHardwareToMax();
413
414
	// Equip the ship with as many lasers as it can hold
415
	$weaponIDs = [
416
		WEAPON_TYPE_PLANETARY_PULSE_LASER,
417
		WEAPON_TYPE_HUGE_PULSE_LASER,
418
		WEAPON_TYPE_HUGE_PULSE_LASER,
419
		WEAPON_TYPE_LARGE_PULSE_LASER,
420
		WEAPON_TYPE_LARGE_PULSE_LASER,
421
		WEAPON_TYPE_LARGE_PULSE_LASER,
422
		WEAPON_TYPE_LASER,
423
	];
424
	$ship->removeAllWeapons();
425
	while ($ship->hasOpenWeaponSlots()) {
426
		$weapon = SmrWeapon::getWeapon(array_shift($weaponIDs));
427
		$ship->addWeapon($weapon);
428
	}
429
430
	// Enable special hardware
431
	if ($ship->hasCloak()) {
432
		$ship->enableCloak();
433
	}
434
	if ($ship->hasIllusion()) {
435
		$illusionShipID = array_rand_value(SHIP_UPGRADE_PATH[$player->getRaceID()]);
0 ignored issues
show
The function array_rand_value was not found. Maybe you did not declare it correctly or list all dependencies? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

435
		$illusionShipID = /** @scrutinizer ignore-call */ array_rand_value(SHIP_UPGRADE_PATH[$player->getRaceID()]);
Loading history...
436
		$ship->setIllusion($illusionShipID, rand(8, 25), rand(6, 20));
437
	}
438
439
	// Update database (not essential to have a lock here)
440
	$player->update();
441
	$ship->update();
442
}
443
444
/**
445
 * @return array<Smr\Routes\MultiplePortRoute>
446
 */
447
function findRoutes(AbstractSmrPlayer $player): array {
448
	debug('Finding Routes');
449
450
	$tradeGoods = [GOODS_NOTHING => false];
451
	foreach (TradeGood::getAll() as $goodID => $good) {
452
		if ($player->meetsAlignmentRestriction($good->alignRestriction)) {
453
			$tradeGoods[$goodID] = true;
454
		} else {
455
			$tradeGoods[$goodID] = false;
456
		}
457
	}
458
459
	// Only allow NPCs to trade at ports of their race and neutral ports
460
	$tradeRaces = [];
461
	foreach (Race::getAllIDs() as $raceID) {
462
		$tradeRaces[$raceID] = false;
463
	}
464
	$tradeRaces[$player->getRaceID()] = true;
465
	$tradeRaces[RACE_NEUTRAL] = true;
466
467
	// Trade in all Racial/Neutral galaxies up until the first Planet galaxy
468
	$galaxies = [];
469
	foreach ($player->getGame()->getGalaxies() as $galaxy) {
470
		if ($galaxy->getGalaxyType() == SmrGalaxy::TYPE_PLANET) {
471
			break;
472
		}
473
		$galaxies[] = $galaxy;
474
	}
475
	// Fallback to current galaxy in case this has selected no galaxies
476
	if (count($galaxies) == 0) {
477
		$galaxies[] = $player->getSector()->getGalaxy();
478
	}
479
480
	// Determine the trade area (start of first galaxy to end of last)
481
	$startSectorID = reset($galaxies)->getStartSector();
482
	$endSectorID = end($galaxies)->getEndSector();
483
484
	$maxNumberOfPorts = 2;
485
	$routesForPort = -1;
486
	$numberOfRoutes = 100;
487
	$maxDistance = 15;
488
489
	$db = Database::getInstance();
490
	$dbResult = $db->read('SELECT routes FROM route_cache WHERE game_id=' . $db->escapeNumber($player->getGameID()) . ' AND max_ports=' . $db->escapeNumber($maxNumberOfPorts) . ' AND goods_allowed=' . $db->escapeObject($tradeGoods) . ' AND races_allowed=' . $db->escapeObject($tradeRaces) . ' AND start_sector_id=' . $db->escapeNumber($startSectorID) . ' AND end_sector_id=' . $db->escapeNumber($endSectorID) . ' AND routes_for_port=' . $db->escapeNumber($routesForPort) . ' AND max_distance=' . $db->escapeNumber($maxDistance));
491
	if ($dbResult->hasRecord()) {
492
		$routes = $dbResult->record()->getObject('routes', true);
493
		debug('Using Cached Routes: #' . count($routes));
494
		return $routes;
495
	}
496
497
	debug('Generating Routes');
498
	$ports = [];
499
	foreach ($galaxies as $galaxy) {
500
		$ports += $galaxy->getPorts(); // Merge arrays
501
	}
502
503
	$distances = Plotter::calculatePortToPortDistances($ports, $tradeRaces, $maxDistance, $startSectorID, $endSectorID);
504
505
	$allRoutes = RouteGenerator::generateMultiPortRoutes($maxNumberOfPorts, $ports, $tradeGoods, $tradeRaces, $distances, $routesForPort, $numberOfRoutes);
506
507
	unset($distances);
508
509
	$routesMerged = [];
510
	foreach ($allRoutes[RouteGenerator::MONEY_ROUTE] as $routesByMulti) {
511
		$routesMerged += $routesByMulti; //Merge arrays
512
	}
513
514
	SmrPort::clearCache();
515
	SmrSector::clearCache();
516
517
	if (count($routesMerged) == 0) {
518
		debug('Could not find any routes! Try another NPC.');
519
		throw new FinalAction();
520
	}
521
522
	$db->insert('route_cache', [
523
		'game_id' => $db->escapeNumber($player->getGameID()),
524
		'max_ports' => $db->escapeNumber($maxNumberOfPorts),
525
		'goods_allowed' => $db->escapeObject($tradeGoods),
526
		'races_allowed' => $db->escapeObject($tradeRaces),
527
		'start_sector_id' => $db->escapeNumber($startSectorID),
528
		'end_sector_id' => $db->escapeNumber($endSectorID),
529
		'routes_for_port' => $db->escapeNumber($routesForPort),
530
		'max_distance' => $db->escapeNumber($maxDistance),
531
		'routes' => $db->escapeObject($routesMerged, true),
532
	]);
533
	debug('Found Routes: #' . count($routesMerged));
534
	return $routesMerged;
535
}
536