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

Failed Conditions
Pull Request — main (#1487)
by Dan
06:01
created

AbstractSmrPort::getMaxLevelByGame()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 6
nc 2
nop 1
dl 0
loc 8
rs 10
c 0
b 0
f 0
1
<?php declare(strict_types=1);
2
3
use Smr\BountyType;
4
use Smr\Database;
5
use Smr\DatabaseRecord;
6
use Smr\Epoch;
7
use Smr\PortPayoutType;
8
use Smr\TransactionType;
9
10
class AbstractSmrPort {
11
12
	use Traits\RaceID;
13
14
	/** @var array<int, array<int, SmrPort>> */
15
	protected static array $CACHE_PORTS = [];
16
	/** @var array<int, array<int, array<int, SmrPort>>> */
17
	protected static array $CACHE_CACHED_PORTS = [];
18
19
	public const DAMAGE_NEEDED_FOR_ALIGNMENT_CHANGE = 300; // single player
20
	protected const DAMAGE_NEEDED_FOR_DOWNGRADE_CHANCE = 325; // all attackers
21
	protected const CHANCE_TO_DOWNGRADE = 1;
22
	protected const TIME_FEDS_STAY = 1800;
23
	protected const MAX_FEDS_BONUS = 4000;
24
	protected const BASE_CDS = 725;
25
	protected const CDS_PER_LEVEL = 100;
26
	protected const CDS_PER_TEN_MIL_CREDITS = 25;
27
	protected const BASE_DEFENCES = 500;
28
	protected const DEFENCES_PER_LEVEL = 700;
29
	protected const DEFENCES_PER_TEN_MIL_CREDITS = 250;
30
	protected const BASE_REFRESH_PER_HOUR = [
31
		'1' => 150,
32
		'2' => 110,
33
		'3' => 70,
34
	];
35
	protected const REFRESH_PER_GOOD = .9;
36
	protected const TIME_TO_CREDIT_RAID = 10800; // 3 hours
37
	protected const GOODS_TRADED_MONEY_MULTIPLIER = 50;
38
	protected const BASE_PAYOUT = 0.85; // fraction of credits for looting
39
	public const RAZE_PAYOUT = 0.75; // fraction of base payout for razing
40
	public const KILLER_RELATIONS_LOSS = 45; // relations lost by killer in PR
41
42
	protected Database $db;
43
	protected readonly string $SQL;
44
45
	protected int $shields;
46
	protected int $combatDrones;
47
	protected int $armour;
48
	protected int $reinforceTime;
49
	protected int $attackStarted;
50
	protected int $level;
51
	protected int $credits;
52
	protected int $upgrade;
53
	protected int $experience;
54
55
	/** @var array<int, int> */
56
	protected array $goodAmounts;
57
	/** @var array<int, bool> */
58
	protected array $goodAmountsChanged = [];
59
	/** @var array<int, TransactionType> */
60
	protected array $goodTransactions;
61
	/** @var array<int, bool> */
62
	protected array $goodTransactionsChanged = [];
63
	/** @var array<int, int> */
64
	protected array $goodDistances;
65
66
	protected bool $cachedVersion = false;
67
	protected int $cachedTime;
68
	protected bool $cacheIsValid = true;
69
70
	protected bool $hasChanged = false;
71
	protected bool $isNew = false;
72
73
	public static function clearCache(): void {
74
		self::$CACHE_PORTS = [];
75
		self::$CACHE_CACHED_PORTS = [];
76
	}
77
78
	/**
79
	 * @return array<int, SmrPort>
80
	 */
81
	public static function getGalaxyPorts(int $gameID, int $galaxyID, bool $forceUpdate = false): array {
82
		$db = Database::getInstance();
83
		// Use a left join so that we populate the cache for every sector
84
		$dbResult = $db->read('SELECT port.* FROM port LEFT JOIN sector USING(game_id, sector_id) WHERE game_id = ' . $db->escapeNumber($gameID) . ' AND galaxy_id = ' . $db->escapeNumber($galaxyID));
85
		$galaxyPorts = [];
86
		foreach ($dbResult->records() as $dbRecord) {
87
			$sectorID = $dbRecord->getInt('sector_id');
88
			$port = self::getPort($gameID, $sectorID, $forceUpdate, $dbRecord);
89
			// Only return those ports that exist
90
			if ($port->exists()) {
91
				$galaxyPorts[$sectorID] = $port;
92
			}
93
		}
94
		return $galaxyPorts;
95
	}
96
97
	public static function getPort(int $gameID, int $sectorID, bool $forceUpdate = false, DatabaseRecord $dbRecord = null): SmrPort {
98
		if ($forceUpdate || !isset(self::$CACHE_PORTS[$gameID][$sectorID])) {
99
			self::$CACHE_PORTS[$gameID][$sectorID] = new SmrPort($gameID, $sectorID, $dbRecord);
100
		}
101
		return self::$CACHE_PORTS[$gameID][$sectorID];
102
	}
103
104
	public static function removePort(int $gameID, int $sectorID): void {
105
		$db = Database::getInstance();
106
		$SQL = 'game_id = ' . $db->escapeNumber($gameID) . '
107
		        AND sector_id = ' . $db->escapeNumber($sectorID);
108
		$db->write('DELETE FROM port WHERE ' . $SQL);
109
		$db->write('DELETE FROM port_has_goods WHERE ' . $SQL);
110
		$db->write('DELETE FROM player_visited_port WHERE ' . $SQL);
111
		$db->write('DELETE FROM player_attacks_port WHERE ' . $SQL);
112
		$db->write('DELETE FROM port_info_cache WHERE ' . $SQL);
113
		self::$CACHE_PORTS[$gameID][$sectorID] = null;
114
		unset(self::$CACHE_PORTS[$gameID][$sectorID]);
115
	}
116
117
	public static function createPort(int $gameID, int $sectorID): SmrPort {
118
		if (!isset(self::$CACHE_PORTS[$gameID][$sectorID])) {
119
			$p = new SmrPort($gameID, $sectorID);
120
			self::$CACHE_PORTS[$gameID][$sectorID] = $p;
121
		}
122
		return self::$CACHE_PORTS[$gameID][$sectorID];
123
	}
124
125
	public static function savePorts(): void {
126
		foreach (self::$CACHE_PORTS as $gamePorts) {
127
			foreach ($gamePorts as $port) {
128
				$port->update();
129
			}
130
		}
131
	}
132
133
	public static function getBaseExperience(int $cargo, int $distance): float {
134
		return ($cargo / 13) * $distance;
135
	}
136
137
	protected function __construct(
138
		protected readonly int $gameID,
139
		protected readonly int $sectorID,
140
		DatabaseRecord $dbRecord = null
141
	) {
142
		$this->cachedTime = Epoch::time();
143
		$this->db = Database::getInstance();
144
		$this->SQL = 'sector_id = ' . $this->db->escapeNumber($sectorID) . ' AND game_id = ' . $this->db->escapeNumber($gameID);
0 ignored issues
show
Bug introduced by
The property SQL is declared read-only in AbstractSmrPort.
Loading history...
145
146
		if ($dbRecord === null) {
147
			$dbResult = $this->db->read('SELECT * FROM port WHERE ' . $this->SQL);
148
			if ($dbResult->hasRecord()) {
149
				$dbRecord = $dbResult->record();
150
			}
151
		}
152
		$this->isNew = $dbRecord === null;
153
154
		if (!$this->isNew) {
155
			$this->shields = $dbRecord->getInt('shields');
156
			$this->combatDrones = $dbRecord->getInt('combat_drones');
157
			$this->armour = $dbRecord->getInt('armour');
158
			$this->reinforceTime = $dbRecord->getInt('reinforce_time');
159
			$this->attackStarted = $dbRecord->getInt('attack_started');
160
			$this->raceID = $dbRecord->getInt('race_id');
161
			$this->level = $dbRecord->getInt('level');
162
			$this->credits = $dbRecord->getInt('credits');
163
			$this->upgrade = $dbRecord->getInt('upgrade');
164
			$this->experience = $dbRecord->getInt('experience');
165
166
			$this->checkDefenses();
167
			$this->getGoods();
168
			$this->checkForUpgrade();
169
		} else {
170
			$this->shields = 0;
171
			$this->combatDrones = 0;
172
			$this->armour = 0;
173
			$this->reinforceTime = 0;
174
			$this->attackStarted = 0;
175
			$this->raceID = RACE_NEUTRAL;
176
			$this->level = 0;
177
			$this->credits = 0;
178
			$this->upgrade = 0;
179
			$this->experience = 0;
180
		}
181
	}
182
183
	public function checkDefenses(): void {
184
		if (!$this->isUnderAttack()) {
185
			$defences = self::BASE_DEFENCES + $this->getLevel() * self::DEFENCES_PER_LEVEL;
186
			$cds = self::BASE_CDS + $this->getLevel() * self::CDS_PER_LEVEL;
187
			// Upgrade modifier
188
			$defences += max(0, IRound(self::DEFENCES_PER_LEVEL * $this->getUpgrade() / $this->getUpgradeRequirement()));
0 ignored issues
show
Bug introduced by
The function IRound 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

188
			$defences += max(0, /** @scrutinizer ignore-call */ IRound(self::DEFENCES_PER_LEVEL * $this->getUpgrade() / $this->getUpgradeRequirement()));
Loading history...
189
			$cds += max(0, IRound(self::CDS_PER_LEVEL * $this->getUpgrade() / $this->getUpgradeRequirement()));
190
			// Credits modifier
191
			$defences += max(0, IRound(self::DEFENCES_PER_TEN_MIL_CREDITS * $this->getCredits() / 10000000));
192
			$cds += max(0, IRound(self::CDS_PER_TEN_MIL_CREDITS * $this->getCredits() / 10000000));
193
			// Defences restock (check for fed arrival)
194
			if (Epoch::time() < $this->getReinforceTime() + self::TIME_FEDS_STAY) {
195
				$federalMod = (self::TIME_FEDS_STAY - (Epoch::time() - $this->getReinforceTime())) / self::TIME_FEDS_STAY;
196
				$federalMod = max(0, IRound($federalMod * self::MAX_FEDS_BONUS));
197
				$defences += $federalMod;
198
				$cds += IRound($federalMod / 10);
199
			}
200
			$this->setShields($defences);
201
			$this->setArmour($defences);
202
			$this->setCDs($cds);
203
			if ($this->getCredits() == 0) {
204
				$this->setCreditsToDefault();
205
			}
206
			$this->db->write('DELETE FROM player_attacks_port WHERE ' . $this->SQL);
207
		}
208
	}
209
210
	/**
211
	 * Used for the automatic resupplying of all goods over time
212
	 */
213
	private function restockGood(int $goodID, int $secondsSinceLastUpdate): void {
214
		if ($secondsSinceLastUpdate <= 0) {
215
			return;
216
		}
217
218
		$goodClass = Globals::getGood($goodID)['Class'];
219
		$refreshPerHour = self::BASE_REFRESH_PER_HOUR[$goodClass] * $this->getGame()->getGameSpeed();
220
		$refreshPerSec = $refreshPerHour / 3600;
221
		$amountToAdd = IFloor($secondsSinceLastUpdate * $refreshPerSec);
0 ignored issues
show
Bug introduced by
The function IFloor 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

221
		$amountToAdd = /** @scrutinizer ignore-call */ IFloor($secondsSinceLastUpdate * $refreshPerSec);
Loading history...
222
223
		// We will not save automatic resupplying in the database,
224
		// because the stock can be correctly recalculated based on the
225
		// last_update time. We will only do the update for player actions
226
		// that affect the stock. This avoids many unnecessary db queries.
227
		$doUpdateDB = false;
228
		$amount = $this->getGoodAmount($goodID);
229
		$this->setGoodAmount($goodID, $amount + $amountToAdd, $doUpdateDB);
230
	}
231
232
	// Sets the class members that identify port trade goods
233
	private function getGoods(): void {
234
		if ($this->isCachedVersion()) {
235
			throw new Exception('Cannot call getGoods on cached port');
236
		}
237
		if (!isset($this->goodAmounts)) {
238
			$dbResult = $this->db->read('SELECT * FROM port_has_goods WHERE ' . $this->SQL . ' ORDER BY good_id ASC');
239
			foreach ($dbResult->records() as $dbRecord) {
240
				$goodID = $dbRecord->getInt('good_id');
241
				$this->goodTransactions[$goodID] = TransactionType::from($dbRecord->getString('transaction_type'));
242
				$this->goodAmounts[$goodID] = $dbRecord->getInt('amount');
243
244
				$secondsSinceLastUpdate = Epoch::time() - $dbRecord->getInt('last_update');
245
				$this->restockGood($goodID, $secondsSinceLastUpdate);
246
			}
247
		}
248
	}
249
250
	/**
251
	 * @param array<int> $goodIDs
252
	 * @return array<int>
253
	 */
254
	private function getVisibleGoods(array $goodIDs, AbstractSmrPlayer $player = null): array {
255
		if ($player == null) {
256
			return $goodIDs;
257
		}
258
		return array_filter($goodIDs, function($goodID) use ($player) {
259
			$good = Globals::getGood($goodID);
260
			return $player->meetsAlignmentRestriction($good['AlignRestriction']);
261
		});
262
	}
263
264
	/**
265
	 * Get IDs of goods that can be sold by $player to the port
266
	 *
267
	 * @return array<int>
268
	 */
269
	public function getVisibleGoodsSold(AbstractSmrPlayer $player = null): array {
270
		return $this->getVisibleGoods($this->getSellGoodIDs(), $player);
271
	}
272
273
	/**
274
	 * Get IDs of goods that can be bought by $player from the port
275
	 *
276
	 * @return array<int>
277
	 */
278
	public function getVisibleGoodsBought(AbstractSmrPlayer $player = null): array {
279
		return $this->getVisibleGoods($this->getBuyGoodIDs(), $player);
280
	}
281
282
	/**
283
	 * @return array<int>
284
	 */
285
	public function getAllGoodIDs(): array {
286
		return array_keys($this->goodTransactions);
287
	}
288
289
	/**
290
	 * Get IDs of goods that can be sold to the port by the trader
291
	 *
292
	 * @return array<int>
293
	 */
294
	public function getSellGoodIDs(): array {
295
		return array_keys($this->goodTransactions, TransactionType::Sell, true);
296
	}
297
298
	/**
299
	 * Get IDs of goods that can be bought from the port by the trader
300
	 *
301
	 * @return array<int>
302
	 */
303
	public function getBuyGoodIDs(): array {
304
		return array_keys($this->goodTransactions, TransactionType::Buy, true);
305
	}
306
307
	public function getGoodDistance(int $goodID): int {
308
		if (!isset($this->goodDistances[$goodID])) {
309
			$x = Globals::getGood($goodID);
310
			// Calculate distance to the opposite of the offered transaction
311
			$x['TransactionType'] = $this->getGoodTransaction($goodID)->opposite();
312
			$di = Plotter::findDistanceToX($x, $this->getSector(), true);
313
			if (is_object($di)) {
0 ignored issues
show
introduced by
The condition is_object($di) is always false.
Loading history...
314
				$di = $di->getDistance();
315
			}
316
			$this->goodDistances[$goodID] = max(1, $di);
317
		}
318
		return $this->goodDistances[$goodID];
319
	}
320
321
	/**
322
	 * Returns the transaction type for this good (Buy or Sell).
323
	 * Note: this is the player's transaction, not the port's.
324
	 */
325
	public function getGoodTransaction(int $goodID): TransactionType {
326
		foreach (TransactionType::cases() as $transaction) {
327
			if ($this->hasGood($goodID, $transaction)) {
328
				return $transaction;
329
			}
330
		}
331
		throw new Exception('Port does not trade goodID ' . $goodID);
332
	}
333
334
	public function hasGood(int $goodID, ?TransactionType $type = null): bool {
335
		$hasGood = isset($this->goodTransactions[$goodID]);
336
		if ($type === null || $hasGood === false) {
337
			return $hasGood;
338
		}
339
		return $this->goodTransactions[$goodID] === $type;
340
	}
341
342
	private function setGoodAmount(int $goodID, int $amount, bool $doUpdate = true): void {
343
		if ($this->isCachedVersion()) {
344
			throw new Exception('Cannot update a cached port!');
345
		}
346
		// The new amount must be between 0 and the max for this good
347
		$amount = max(0, min($amount, Globals::getGood($goodID)['Max']));
348
		if ($this->getGoodAmount($goodID) == $amount) {
349
			return;
350
		}
351
		$this->goodAmounts[$goodID] = $amount;
352
353
		if ($doUpdate) {
354
			// This goodID will be changed in the db during `update()`
355
			$this->goodAmountsChanged[$goodID] = true;
356
		}
357
	}
358
359
	public function getGoodAmount(int $goodID): int {
360
		return $this->goodAmounts[$goodID];
361
	}
362
363
	/**
364
	 * @param array<string, string|int> $good
365
	 */
366
	public function decreaseGood(array $good, int $amount, bool $doRefresh): void {
367
		$this->setGoodAmount($good['ID'], $this->getGoodAmount($good['ID']) - $amount);
0 ignored issues
show
Bug introduced by
It seems like $good['ID'] can also be of type string; however, parameter $goodID of AbstractSmrPort::setGoodAmount() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

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

367
		$this->setGoodAmount(/** @scrutinizer ignore-type */ $good['ID'], $this->getGoodAmount($good['ID']) - $amount);
Loading history...
Bug introduced by
It seems like $good['ID'] can also be of type string; however, parameter $goodID of AbstractSmrPort::getGoodAmount() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

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

367
		$this->setGoodAmount($good['ID'], $this->getGoodAmount(/** @scrutinizer ignore-type */ $good['ID']) - $amount);
Loading history...
368
		if ($doRefresh === true) {
369
			//get id of goods to replenish
370
			$this->refreshGoods($good['Class'], $amount);
0 ignored issues
show
Bug introduced by
It seems like $good['Class'] can also be of type string; however, parameter $classTraded of AbstractSmrPort::refreshGoods() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

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

370
			$this->refreshGoods(/** @scrutinizer ignore-type */ $good['Class'], $amount);
Loading history...
371
		}
372
	}
373
374
	public function increaseGoodAmount(int $goodID, int $amount): void {
375
		$this->setGoodAmount($goodID, $this->getGoodAmount($goodID) + $amount);
376
	}
377
378
	public function decreaseGoodAmount(int $goodID, int $amount): void {
379
		$this->setGoodAmount($goodID, $this->getGoodAmount($goodID) - $amount);
380
	}
381
382
	/**
383
	 * Adds extra stock to goods in the tier above a good that was traded
384
	 */
385
	protected function refreshGoods(int $classTraded, int $amountTraded): void {
386
		$refreshAmount = IRound($amountTraded * self::REFRESH_PER_GOOD);
0 ignored issues
show
Bug introduced by
The function IRound 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

386
		$refreshAmount = /** @scrutinizer ignore-call */ IRound($amountTraded * self::REFRESH_PER_GOOD);
Loading history...
387
		//refresh goods that need it
388
		$refreshClass = $classTraded + 1;
389
		foreach ($this->getAllGoodIDs() as $goodID) {
390
			$goodClass = Globals::getGood($goodID)['Class'];
391
			if ($goodClass == $refreshClass) {
392
				$this->increaseGoodAmount($goodID, $refreshAmount);
393
			}
394
		}
395
	}
396
397
	/**
398
	 * @param array<string, string|int> $good
399
	 */
400
	protected function tradeGoods(array $good, int $goodsTraded, int $exp): void {
401
		$goodsTradedMoney = $goodsTraded * self::GOODS_TRADED_MONEY_MULTIPLIER;
402
		$this->increaseUpgrade($goodsTradedMoney);
403
		$this->increaseCredits($goodsTradedMoney);
404
		$this->increaseExperience($exp);
405
		$this->decreaseGood($good, $goodsTraded, true);
406
	}
407
408
	/**
409
	 * @param array<string, string|int> $good
410
	 */
411
	public function buyGoods(array $good, int $goodsTraded, int $idealPrice, int $bargainPrice, int $exp): void {
412
		$this->tradeGoods($good, $goodsTraded, $exp);
413
		// Limit upgrade/credits to prevent massive increases in a single trade
414
		$cappedBargainPrice = min(max($idealPrice, $goodsTraded * 1000), $bargainPrice);
415
		$this->increaseUpgrade($cappedBargainPrice);
416
		$this->increaseCredits($cappedBargainPrice);
417
	}
418
419
	/**
420
	 * @param array<string, string|int> $good
421
	 */
422
	public function sellGoods(array $good, int $goodsTraded, int $exp): void {
423
		$this->tradeGoods($good, $goodsTraded, $exp);
424
	}
425
426
	/**
427
	 * @param array<string, string|int> $good
428
	 */
429
	public function stealGoods(array $good, int $goodsTraded): void {
430
		$this->decreaseGood($good, $goodsTraded, false);
431
	}
432
433
	public function checkForUpgrade(): int {
434
		if ($this->isCachedVersion()) {
435
			throw new Exception('Cannot upgrade a cached port!');
436
		}
437
		$upgrades = 0;
438
		while ($this->upgrade >= $this->getUpgradeRequirement() && $this->level < $this->getMaxLevel()) {
439
			++$upgrades;
440
			$this->decreaseUpgrade($this->getUpgradeRequirement());
441
			$this->decreaseCredits($this->getUpgradeRequirement());
442
			$this->doUpgrade();
443
		}
444
		return $upgrades;
445
	}
446
447
	/**
448
	 * This function should only be used in universe creation to set
449
	 * ports to a specific level.
450
	 */
451
	public function upgradeToLevel(int $level): void {
452
		if ($this->isCachedVersion()) {
453
			throw new Exception('Cannot upgrade a cached port!');
454
		}
455
		while ($this->getLevel() < $level) {
456
			$this->doUpgrade();
457
		}
458
		while ($this->getLevel() > $level) {
459
			$this->doDowngrade();
460
		}
461
	}
462
463
	/**
464
	 * Returns the good class associated with the given level.
465
	 * If no level specified, will use the current port level.
466
	 * This is useful for determining what trade goods to add/remove.
467
	 */
468
	protected function getGoodClassAtLevel(int $level = null): int {
469
		if ($level === null) {
470
			$level = $this->getLevel();
471
		}
472
		return match ($level) {
473
			1, 2 => 1,
474
			3, 4, 5, 6 => 2,
475
			7, 8, 9 => 3,
476
			default => throw new Exception('No good class for level ' . $level),
477
		};
478
	}
479
480
	/**
481
	 * @return array<string, string|int>
482
	 */
483
	protected function selectAndAddGood(int $goodClass): array {
484
		$GOODS = Globals::getGoods();
485
		shuffle($GOODS);
486
		foreach ($GOODS as $good) {
487
			if (!$this->hasGood($good['ID']) && $good['Class'] == $goodClass) {
488
				$transactionType = array_rand_value(TransactionType::cases());
0 ignored issues
show
Bug introduced by
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

488
				$transactionType = /** @scrutinizer ignore-call */ array_rand_value(TransactionType::cases());
Loading history...
489
				$this->addPortGood($good['ID'], $transactionType);
490
				return $good;
491
			}
492
		}
493
		throw new Exception('Failed to add a good!');
494
	}
495
496
	protected function doUpgrade(): void {
497
		if ($this->isCachedVersion()) {
498
			throw new Exception('Cannot upgrade a cached port!');
499
		}
500
501
		$this->increaseLevel(1);
502
		$goodClass = $this->getGoodClassAtLevel();
503
		$this->selectAndAddGood($goodClass);
504
505
		if ($this->getLevel() == 1) {
506
			// Add 2 extra goods when upgrading to level 1 (i.e. in Uni Gen)
507
			$this->selectAndAddGood($goodClass);
508
			$this->selectAndAddGood($goodClass);
509
		}
510
	}
511
512
	public function getUpgradeRequirement(): int {
513
		//return round(exp($this->getLevel()/1.7)+3)*1000000;
514
		return $this->getLevel() * 1000000;
515
	}
516
517
	/**
518
	 * Manually set port goods.
519
	 * Only modifies goods that need to change.
520
	 * Returns false on invalid input.
521
	 *
522
	 * @param array<int, TransactionType> $goods
523
	 */
524
	public function setPortGoods(array $goods): bool {
525
		// Validate the input list of goods to make sure we have the correct
526
		// number of each good class for this port level.
527
		$givenClasses = [];
528
		foreach (array_keys($goods) as $goodID) {
529
			$givenClasses[] = Globals::getGood($goodID)['Class'];
530
		}
531
		$expectedClasses = [1, 1]; // Level 1 has 2 extra Class 1 goods
532
		foreach (range(1, $this->getLevel()) as $level) {
533
			$expectedClasses[] = $this->getGoodClassAtLevel($level);
534
		}
535
		if ($givenClasses != $expectedClasses) {
536
			return false;
537
		}
538
539
		// Remove goods not specified or that have the wrong transaction
540
		foreach ($this->getAllGoodIDs() as $goodID) {
541
			if (!isset($goods[$goodID]) || !$this->hasGood($goodID, $goods[$goodID])) {
542
				$this->removePortGood($goodID);
543
			}
544
		}
545
		// Add goods
546
		foreach ($goods as $goodID => $trans) {
547
			$this->addPortGood($goodID, $trans);
548
		}
549
		return true;
550
	}
551
552
	/**
553
	 * Add good with given ID to the port, with transaction $type
554
	 * as either "Buy" or "Sell", meaning the player buys or sells.
555
	 * If the port already has this transaction, do nothing.
556
	 *
557
	 * NOTE: make sure to adjust the port level appropriately if
558
	 * calling this function directly.
559
	 */
560
	public function addPortGood(int $goodID, TransactionType $type): void {
561
		if ($this->isCachedVersion()) {
562
			throw new Exception('Cannot update a cached port!');
563
		}
564
		if ($this->hasGood($goodID, $type)) {
565
			return;
566
		}
567
568
		$this->goodTransactions[$goodID] = $type;
569
		// sort ID arrays, since the good ID might not be the largest
570
		ksort($this->goodTransactions);
571
572
		$this->goodAmounts[$goodID] = Globals::getGood($goodID)['Max'];
573
574
		// Flag for update
575
		$this->cacheIsValid = false;
576
		$this->goodTransactionsChanged[$goodID] = true; // true => added
577
	}
578
579
	/**
580
	 * Remove good with given ID from the port.
581
	 * If the port does not have this good, do nothing.
582
	 *
583
	 * NOTE: make sure to adjust the port level appropriately if
584
	 * calling this function directly.
585
	 */
586
	public function removePortGood(int $goodID): void {
587
		if ($this->isCachedVersion()) {
588
			throw new Exception('Cannot update a cached port!');
589
		}
590
		if (!$this->hasGood($goodID)) {
591
			return;
592
		}
593
594
		unset($this->goodAmounts[$goodID]);
595
		unset($this->goodAmountsChanged[$goodID]);
596
		unset($this->goodTransactions[$goodID]);
597
		unset($this->goodDistances[$goodID]);
598
599
		// Flag for update
600
		$this->cacheIsValid = false;
601
		$this->goodTransactionsChanged[$goodID] = false; // false => removed
602
	}
603
604
	/**
605
	 * Returns the number of port level downgrades due to damage taken.
606
	 */
607
	public function checkForDowngrade(int $damage): int {
608
		$numDowngrades = 0;
609
		$numChances = floor($damage / self::DAMAGE_NEEDED_FOR_DOWNGRADE_CHANCE);
610
		for ($i = 0; $i < $numChances; $i++) {
611
			if (rand(1, 100) <= self::CHANCE_TO_DOWNGRADE && $this->level > 1) {
612
				++$numDowngrades;
613
				$this->doDowngrade();
614
			}
615
		}
616
		return $numDowngrades;
617
	}
618
619
	protected function selectAndRemoveGood(int $goodClass): void {
620
		// Pick good to remove from the list of goods the port currently has
621
		$goodIDs = $this->getAllGoodIDs();
622
		shuffle($goodIDs);
623
624
		foreach ($goodIDs as $goodID) {
625
			$good = Globals::getGood($goodID);
626
			if ($good['Class'] == $goodClass) {
627
				$this->removePortGood($good['ID']);
628
				return;
629
			}
630
		}
631
		throw new Exception('Failed to remove a good!');
632
	}
633
634
	protected function doDowngrade(): void {
635
		if ($this->isCachedVersion()) {
636
			throw new Exception('Cannot downgrade a cached port!');
637
		}
638
639
		$goodClass = $this->getGoodClassAtLevel();
640
		$this->selectAndRemoveGood($goodClass);
641
642
		if ($this->getLevel() == 1) {
643
			// For level 1 ports, we don't want to have fewer goods
644
			$newGood = $this->selectAndAddGood($goodClass);
645
			// Set new good to 0 supply
646
			// (since other goods are set to 0 when port is destroyed)
647
			$this->setGoodAmount($newGood['ID'], 0);
648
		} else {
649
			// Don't make the port level 0
650
			$this->decreaseLevel(1);
651
		}
652
		$this->setUpgrade(0);
653
	}
654
655
	/**
656
	 * @param array<AbstractSmrPlayer> $attackers
657
	 */
658
	public function attackedBy(AbstractSmrPlayer $trigger, array $attackers): void {
659
		if ($this->isCachedVersion()) {
660
			throw new Exception('Cannot attack a cached port!');
661
		}
662
663
		$trigger->increaseHOF(1, ['Combat', 'Port', 'Number Of Triggers'], HOF_PUBLIC);
664
		foreach ($attackers as $attacker) {
665
			$attacker->increaseHOF(1, ['Combat', 'Port', 'Number Of Attacks'], HOF_PUBLIC);
666
			$this->db->replace('player_attacks_port', [
667
				'game_id' => $this->db->escapeNumber($this->getGameID()),
668
				'account_id' => $this->db->escapeNumber($attacker->getAccountID()),
669
				'sector_id' => $this->db->escapeNumber($this->getSectorID()),
670
				'time' => $this->db->escapeNumber(Epoch::time()),
671
				'level' => $this->db->escapeNumber($this->getLevel()),
672
			]);
673
		}
674
		if (!$this->isUnderAttack()) {
675
676
			//5 mins per port level
677
			$nextReinforce = Epoch::time() + $this->getLevel() * 300;
678
679
			$this->setReinforceTime($nextReinforce);
680
			$this->updateAttackStarted();
681
			//add news
682
			$newsMessage = '<span class="red bold">*MAYDAY* *MAYDAY*</span> A distress beacon has been activated by the port in sector ' . Globals::getSectorBBLink($this->getSectorID()) . '. It is under attack by ';
683
			if ($trigger->hasAlliance()) {
684
				$newsMessage .= 'members of ' . $trigger->getAllianceBBLink();
685
			} else {
686
				$newsMessage .= $trigger->getBBLink();
687
			}
688
689
			$newsMessage .= '. The Federal Government is offering ';
690
			$bounty = number_format(floor($trigger->getLevelID() * DEFEND_PORT_BOUNTY_PER_LEVEL));
691
692
			if ($trigger->hasAlliance()) {
693
				$newsMessage .= 'bounties of <span class="creds">' . $bounty . '</span> credits for the deaths of any raiding members of ' . $trigger->getAllianceBBLink();
694
			} else {
695
				$newsMessage .= 'a bounty of <span class="creds">' . $bounty . '</span> credits for the death of ' . $trigger->getBBLink();
696
			}
697
			$newsMessage .= ' prior to the destruction of the port, or until federal forces arrive to defend the port.';
698
699
			$this->db->insert('news', [
700
				'game_id' => $this->db->escapeNumber($this->getGameID()),
701
				'time' => $this->db->escapeNumber(Epoch::time()),
702
				'news_message' => $this->db->escapeString($newsMessage),
703
				'killer_id' => $this->db->escapeNumber($trigger->getAccountID()),
704
				'killer_alliance' => $this->db->escapeNumber($trigger->getAllianceID()),
705
				'dead_id' => $this->db->escapeNumber(ACCOUNT_ID_PORT),
706
			]);
707
		}
708
	}
709
710
	public function getDisplayName(): string {
711
		return '<span style="color:yellow;font-variant:small-caps">Port ' . $this->getSectorID() . '</span>';
712
	}
713
714
	public function setShields(int $shields): void {
715
		if ($this->isCachedVersion()) {
716
			throw new Exception('Cannot update a cached port!');
717
		}
718
		if ($shields < 0) {
719
			$shields = 0;
720
		}
721
		if ($this->shields == $shields) {
722
			return;
723
		}
724
		$this->shields = $shields;
725
		$this->hasChanged = true;
726
	}
727
728
	public function setArmour(int $armour): void {
729
		if ($this->isCachedVersion()) {
730
			throw new Exception('Cannot update a cached port!');
731
		}
732
		if ($armour < 0) {
733
			$armour = 0;
734
		}
735
		if ($this->armour == $armour) {
736
			return;
737
		}
738
		$this->armour = $armour;
739
		$this->hasChanged = true;
740
	}
741
742
	public function setCDs(int $combatDrones): void {
743
		if ($this->isCachedVersion()) {
744
			throw new Exception('Cannot update a cached port!');
745
		}
746
		if ($combatDrones < 0) {
747
			$combatDrones = 0;
748
		}
749
		if ($this->combatDrones == $combatDrones) {
750
			return;
751
		}
752
		$this->combatDrones = $combatDrones;
753
		$this->hasChanged = true;
754
	}
755
756
	public function setCreditsToDefault(): void {
757
		$this->setCredits(2700000 + $this->getLevel() * 1500000 + pow($this->getLevel(), 2) * 300000);
758
	}
759
760
	public function setCredits(int $credits): void {
761
		if ($this->isCachedVersion()) {
762
			throw new Exception('Cannot update a cached port!');
763
		}
764
		if ($this->credits == $credits) {
765
			return;
766
		}
767
		$this->credits = $credits;
768
		$this->hasChanged = true;
769
	}
770
771
	public function decreaseCredits(int $credits): void {
772
		if ($credits < 0) {
773
			throw new Exception('Cannot decrease negative credits.');
774
		}
775
		$this->setCredits($this->getCredits() - $credits);
776
	}
777
778
	public function increaseCredits(int $credits): void {
779
		if ($credits < 0) {
780
			throw new Exception('Cannot increase negative credits.');
781
		}
782
		$this->setCredits($this->getCredits() + $credits);
783
	}
784
785
	public function setUpgrade(int $upgrade): void {
786
		if ($this->isCachedVersion()) {
787
			throw new Exception('Cannot update a cached port!');
788
		}
789
		if ($this->getLevel() == $this->getMaxLevel()) {
790
			$upgrade = 0;
791
		}
792
		if ($this->upgrade == $upgrade) {
793
			return;
794
		}
795
		$this->upgrade = $upgrade;
796
		$this->hasChanged = true;
797
		$this->checkForUpgrade();
798
	}
799
800
	public function decreaseUpgrade(int $upgrade): void {
801
		if ($upgrade < 0) {
802
			throw new Exception('Cannot decrease negative upgrade.');
803
		}
804
		$this->setUpgrade($this->getUpgrade() - $upgrade);
805
	}
806
807
	public function increaseUpgrade(int $upgrade): void {
808
		if ($upgrade < 0) {
809
			throw new Exception('Cannot increase negative upgrade.');
810
		}
811
		$this->setUpgrade($this->getUpgrade() + $upgrade);
812
	}
813
814
	public function setLevel(int $level): void {
815
		if ($this->isCachedVersion()) {
816
			throw new Exception('Cannot update a cached port!');
817
		}
818
		if ($this->level == $level) {
819
			return;
820
		}
821
		$this->level = $level;
822
		$this->hasChanged = true;
823
	}
824
825
	public function increaseLevel(int $level): void {
826
		if ($level < 0) {
827
			throw new Exception('Cannot increase negative level.');
828
		}
829
		$this->setLevel($this->getLevel() + $level);
830
	}
831
832
	public function decreaseLevel(int $level): void {
833
		if ($level < 0) {
834
			throw new Exception('Cannot increase negative level.');
835
		}
836
		$this->setLevel($this->getLevel() - $level);
837
	}
838
839
	public function setExperience(int $experience): void {
840
		if ($this->isCachedVersion()) {
841
			throw new Exception('Cannot update a cached port!');
842
		}
843
		if ($this->experience == $experience) {
844
			return;
845
		}
846
		$this->experience = $experience;
847
		$this->hasChanged = true;
848
	}
849
850
	public function increaseExperience(int $experience): void {
851
		if ($experience < 0) {
852
			throw new Exception('Cannot increase negative experience.');
853
		}
854
		$this->setExperience($this->getExperience() + $experience);
855
	}
856
857
	public function getGameID(): int {
858
		return $this->gameID;
859
	}
860
861
	public function getGame(): SmrGame {
862
		return SmrGame::getGame($this->gameID);
863
	}
864
865
	public function getSectorID(): int {
866
		return $this->sectorID;
867
	}
868
869
	public function getSector(): SmrSector {
870
		return SmrSector::getSector($this->getGameID(), $this->getSectorID());
871
	}
872
873
	public function setRaceID(int $raceID): void {
874
		if ($this->raceID == $raceID) {
875
			return;
876
		}
877
		$this->raceID = $raceID;
878
		$this->hasChanged = true;
879
		$this->cacheIsValid = false;
880
	}
881
882
	public function getLevel(): int {
883
		return $this->level;
884
	}
885
886
	public static function getMaxLevelByGame(int $gameID): int {
887
		$game = SmrGame::getGame($gameID);
888
		if ($game->isGameType(SmrGame::GAME_TYPE_HUNTER_WARS)) {
889
			$maxLevel = 6;
890
		} else {
891
			$maxLevel = 9;
892
		}
893
		return $maxLevel;
894
	}
895
896
	public function getMaxLevel(): int {
897
		return self::getMaxLevelByGame($this->gameID);
898
	}
899
900
	public function getShields(): int {
901
		return $this->shields;
902
	}
903
904
	public function hasShields(): bool {
905
		return ($this->getShields() > 0);
906
	}
907
908
	public function getCDs(): int {
909
		return $this->combatDrones;
910
	}
911
912
	public function hasCDs(): bool {
913
		return ($this->getCDs() > 0);
914
	}
915
916
	public function getArmour(): int {
917
		return $this->armour;
918
	}
919
920
	public function hasArmour(): bool {
921
		return ($this->getArmour() > 0);
922
	}
923
924
	public function getExperience(): int {
925
		return $this->experience;
926
	}
927
928
	public function getCredits(): int {
929
		return $this->credits;
930
	}
931
932
	public function getUpgrade(): int {
933
		return $this->upgrade;
934
	}
935
936
	public function getNumWeapons(): int {
937
		return $this->getLevel() + 3;
938
	}
939
940
	/**
941
	 * @return array<SmrWeapon>
942
	 */
943
	public function getWeapons(): array {
944
		$portTurret = SmrWeapon::getWeapon(WEAPON_PORT_TURRET);
945
		return array_fill(0, $this->getNumWeapons(), $portTurret);
946
	}
947
948
	public function getUpgradePercent(): float {
949
		return min(1, max(0, $this->upgrade / $this->getUpgradeRequirement()));
950
	}
951
952
	public function getCreditsPercent(): float {
953
		return min(1, max(0, $this->credits / 32000000));
954
	}
955
956
	public function getReinforcePercent(): float {
957
		if (!$this->isUnderAttack()) {
958
			return 0;
959
		}
960
		return min(1, max(0, ($this->getReinforceTime() - Epoch::time()) / ($this->getReinforceTime() - $this->getAttackStarted())));
961
	}
962
963
	public function getReinforceTime(): int {
964
		return $this->reinforceTime;
965
	}
966
967
	public function setReinforceTime(int $reinforceTime): void {
968
		if ($this->reinforceTime == $reinforceTime) {
969
			return;
970
		}
971
		$this->reinforceTime = $reinforceTime;
972
		$this->hasChanged = true;
973
	}
974
975
	public function getAttackStarted(): int {
976
		return $this->attackStarted;
977
	}
978
979
	private function updateAttackStarted(): void {
980
		$this->setAttackStarted(Epoch::time());
981
	}
982
983
	private function setAttackStarted(int $time): void {
984
		if ($this->attackStarted == $time) {
985
			return;
986
		}
987
		$this->attackStarted = $time;
988
		$this->hasChanged = true;
989
	}
990
991
	public function isUnderAttack(): bool {
992
		return ($this->getReinforceTime() >= Epoch::time());
993
	}
994
995
	public function isDestroyed(): bool {
996
		return $this->getArmour() < 1;
997
	}
998
999
	public function exists(): bool {
1000
		return $this->isNew === false || $this->hasChanged === true;
1001
	}
1002
1003
	public function decreaseShields(int $number): void {
1004
		$this->setShields($this->getShields() - $number);
1005
	}
1006
1007
	public function decreaseCDs(int $number): void {
1008
		$this->setCDs($this->getCDs() - $number);
1009
	}
1010
1011
	public function decreaseArmour(int $number): void {
1012
		$this->setArmour($this->getArmour() - $number);
1013
	}
1014
1015
	public function getTradeRestriction(AbstractSmrPlayer $player): string|false {
1016
		if (!$this->exists()) {
1017
			return 'There is no port in this sector!';
1018
		}
1019
		if ($this->getSectorID() != $player->getSectorID()) {
1020
			return 'That port is not in this sector!';
1021
		}
1022
		if ($player->getRelation($this->getRaceID()) <= RELATIONS_WAR) {
1023
			return 'We will not trade with our enemies!';
1024
		}
1025
		if ($this->isUnderAttack()) {
1026
			return 'We are still repairing damage caused during the last raid.';
1027
		}
1028
		return false;
1029
	}
1030
1031
	public function getIdealPrice(int $goodID, TransactionType $transactionType, int $numGoods, int $relations): int {
1032
		$supply = $this->getGoodAmount($goodID);
1033
		$dist = $this->getGoodDistance($goodID);
1034
		return self::idealPrice($goodID, $transactionType, $numGoods, $relations, $supply, $dist);
1035
	}
1036
1037
	/**
1038
	 * Generic ideal price calculation, given all parameters as input.
1039
	 */
1040
	public static function idealPrice(int $goodID, TransactionType $transactionType, int $numGoods, int $relations, int $supply, int $dist): int {
1041
		$relations = min(1000, $relations); // no effect for higher relations
1042
		$good = Globals::getGood($goodID);
1043
		$base = $good['BasePrice'] * $numGoods;
1044
		$maxSupply = $good['Max'];
1045
1046
		$distFactor = pow($dist, 1.3);
1047
		if ($transactionType === TransactionType::Sell) {
1048
			$supplyFactor = 1 + ($supply / $maxSupply);
1049
			$relationsFactor = 1.2 + 1.8 * ($relations / 1000); // [0.75-3]
1050
			$scale = 0.088;
1051
		} else { // $transactionType === TransactionType::Buy
1052
			$supplyFactor = 2 - ($supply / $maxSupply);
1053
			$relationsFactor = 3 - 2 * ($relations / 1000);
1054
			$scale = 0.03;
1055
		}
1056
		return IRound($base * $scale * $distFactor * $supplyFactor * $relationsFactor);
0 ignored issues
show
Bug introduced by
The function IRound 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

1056
		return /** @scrutinizer ignore-call */ IRound($base * $scale * $distFactor * $supplyFactor * $relationsFactor);
Loading history...
1057
	}
1058
1059
	public function getOfferPrice(int $idealPrice, int $relations, TransactionType $transactionType): int {
1060
		$relations = min(1000, $relations); // no effect for higher relations
1061
		$relationsEffect = (2 * $relations + 8000) / 10000; // [0.75-1]
1062
1063
		return match ($transactionType) {
1064
			TransactionType::Buy => max($idealPrice, IFloor($idealPrice * (2 - $relationsEffect))),
0 ignored issues
show
Bug introduced by
The function IFloor 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

1064
			TransactionType::Buy => max($idealPrice, /** @scrutinizer ignore-call */ IFloor($idealPrice * (2 - $relationsEffect))),
Loading history...
1065
			TransactionType::Sell => min($idealPrice, ICeil($idealPrice * $relationsEffect)),
0 ignored issues
show
Bug introduced by
The function ICeil 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

1065
			TransactionType::Sell => min($idealPrice, /** @scrutinizer ignore-call */ ICeil($idealPrice * $relationsEffect)),
Loading history...
1066
		};
1067
	}
1068
1069
	/**
1070
	 * Return the fraction of max exp earned.
1071
	 */
1072
	public function calculateExperiencePercent(int $idealPrice, int $bargainPrice, TransactionType $transactionType): float {
1073
		if ($bargainPrice == $idealPrice) {
1074
			return 1;
1075
		}
1076
1077
		$offerPriceNoRelations = $this->getOfferPrice($idealPrice, 0, $transactionType);
1078
1079
		// Avoid division by 0 in the case where the ideal price is so small
1080
		// that relations have no impact on the offered price.
1081
		$denom = max(1, abs($idealPrice - $offerPriceNoRelations));
1082
1083
		$expPercent = 1 - abs(($idealPrice - $bargainPrice) / $denom);
1084
		return max(0, min(1, $expPercent));
1085
	}
1086
1087
	public function getRaidWarningHREF(): string {
1088
		return Page::create('port_attack_warning.php')->href();
1089
	}
1090
1091
	public function getAttackHREF(): string {
1092
		$container = Page::create('port_attack_processing.php');
1093
		$container['port_id'] = $this->getSectorID();
1094
		return $container->href();
1095
	}
1096
1097
	public function getClaimHREF(): string {
1098
		$container = Page::create('port_claim_processing.php');
1099
		$container['port_id'] = $this->getSectorID();
1100
		return $container->href();
1101
	}
1102
1103
	/**
1104
	 * @return ($justContainer is false ? string : Page)
0 ignored issues
show
Documentation Bug introduced by
The doc comment ($justContainer at position 1 could not be parsed: Unknown type name '$justContainer' at position 1 in ($justContainer.
Loading history...
1105
	 */
1106
	public function getRazeHREF(bool $justContainer = false): string|Page {
1107
		$container = Page::create('port_payout_processing.php');
1108
		$container['PayoutType'] = PortPayoutType::Raze;
1109
		return $justContainer === false ? $container->href() : $container;
1110
	}
1111
1112
	/**
1113
	 * @return ($justContainer is false ? string : Page)
0 ignored issues
show
Documentation Bug introduced by
The doc comment ($justContainer at position 1 could not be parsed: Unknown type name '$justContainer' at position 1 in ($justContainer.
Loading history...
1114
	 */
1115
	public function getLootHREF(bool $justContainer = false): string|Page {
1116
		if ($this->getCredits() > 0) {
1117
			$container = Page::create('port_payout_processing.php');
1118
			$container['PayoutType'] = PortPayoutType::Loot;
1119
		} else {
1120
			$container = Page::create('current_sector.php');
1121
			$container['msg'] = 'This port has already been looted.';
1122
		}
1123
		return $justContainer === false ? $container->href() : $container;
1124
	}
1125
1126
	public function getLootGoodHREF(int $boughtGoodID): string {
1127
		$container = Page::create('port_loot_processing.php');
1128
		$container['GoodID'] = $boughtGoodID;
1129
		return $container->href();
1130
	}
1131
1132
	public function isCachedVersion(): bool {
1133
		return $this->cachedVersion;
1134
	}
1135
1136
	public function getCachedTime(): int {
1137
		return $this->cachedTime;
1138
	}
1139
1140
	protected function setCachedTime(int $cachedTime): void {
1141
		$this->cachedTime = $cachedTime;
1142
	}
1143
1144
	public function updateSectorPlayersCache(): void {
1145
		$accountIDs = [];
1146
		$sectorPlayers = $this->getSector()->getPlayers();
1147
		foreach ($sectorPlayers as $sectorPlayer) {
1148
			$accountIDs[] = $sectorPlayer->getAccountID();
1149
		}
1150
		$this->addCachePorts($accountIDs);
1151
	}
1152
1153
	public function addCachePort(int $accountID): void {
1154
		$this->addCachePorts([$accountID]);
1155
	}
1156
1157
	/**
1158
	 * @param array<int> $accountIDs
1159
	 */
1160
	public function addCachePorts(array $accountIDs): bool {
1161
		if (count($accountIDs) > 0 && $this->exists()) {
1162
			$cache = $this->db->escapeObject($this, true);
1163
			$cacheHash = $this->db->escapeString(md5($cache));
1164
			//give them the port info
1165
			$query = 'INSERT IGNORE INTO player_visited_port ' .
1166
						'(account_id, game_id, sector_id, visited, port_info_hash) ' .
1167
						'VALUES ';
1168
			foreach ($accountIDs as $accountID) {
1169
				$query .= '(' . $accountID . ', ' . $this->getGameID() . ', ' . $this->getSectorID() . ', 0, \'\'),';
1170
			}
1171
			$query = substr($query, 0, -1);
1172
			$this->db->write($query);
1173
1174
			$this->db->write('INSERT IGNORE INTO port_info_cache
1175
						(game_id, sector_id, port_info_hash, port_info)
1176
						VALUES (' . $this->db->escapeNumber($this->getGameID()) . ', ' . $this->db->escapeNumber($this->getSectorID()) . ', ' . $cacheHash . ', ' . $cache . ')');
1177
1178
			// We can't use the SQL member here because CachePorts don't have it
1179
			$this->db->write('UPDATE player_visited_port SET visited=' . $this->db->escapeNumber($this->getCachedTime()) . ', port_info_hash=' . $cacheHash . ' WHERE visited<=' . $this->db->escapeNumber($this->getCachedTime()) . ' AND account_id IN (' . $this->db->escapeArray($accountIDs) . ') AND sector_id=' . $this->db->escapeNumber($this->getSectorID()) . ' AND game_id=' . $this->db->escapeNumber($this->getGameID()) . ' LIMIT ' . count($accountIDs));
1180
1181
			unset($cache);
1182
			return true;
1183
		}
1184
		return false;
1185
	}
1186
1187
	public static function getCachedPort(int $gameID, int $sectorID, int $accountID, bool $forceUpdate = false): SmrPort|false {
1188
		if ($forceUpdate || !isset(self::$CACHE_CACHED_PORTS[$gameID][$sectorID][$accountID])) {
1189
			$db = Database::getInstance();
1190
			$dbResult = $db->read('SELECT visited, port_info
1191
						FROM player_visited_port
1192
						JOIN port_info_cache USING (game_id,sector_id,port_info_hash)
1193
						WHERE account_id = ' . $db->escapeNumber($accountID) . '
1194
							AND game_id = ' . $db->escapeNumber($gameID) . '
1195
							AND sector_id = ' . $db->escapeNumber($sectorID) . ' LIMIT 1');
1196
1197
			if ($dbResult->hasRecord()) {
1198
				$dbRecord = $dbResult->record();
1199
				self::$CACHE_CACHED_PORTS[$gameID][$sectorID][$accountID] = $dbRecord->getObject('port_info', true);
1200
				self::$CACHE_CACHED_PORTS[$gameID][$sectorID][$accountID]->setCachedTime($dbRecord->getInt('visited'));
1201
			} else {
1202
				self::$CACHE_CACHED_PORTS[$gameID][$sectorID][$accountID] = false;
1203
			}
1204
		}
1205
		return self::$CACHE_CACHED_PORTS[$gameID][$sectorID][$accountID];
1206
	}
1207
1208
	// This is a magic method used when serializing an SmrPort instance.
1209
	// It designates which members should be included in the serialization.
1210
	public function __sleep() {
1211
		// We omit `goodAmounts` and `goodDistances` so that the hash of the
1212
		// serialized object is the same for all players. This greatly improves
1213
		// cache efficiency.
1214
		return ['gameID', 'sectorID', 'raceID', 'level', 'goodTransactions'];
1215
	}
1216
1217
	public function __wakeup() {
1218
		$this->cachedVersion = true;
1219
		$this->db = Database::getInstance();
1220
	}
1221
1222
	public function update(): void {
1223
		if ($this->isCachedVersion()) {
1224
			throw new Exception('Cannot update a cached port!');
1225
		}
1226
		if (!$this->exists()) {
1227
			return;
1228
		}
1229
1230
		// If any cached members (see `__sleep`) changed, update the cached port
1231
		if (!$this->cacheIsValid) {
1232
			$this->updateSectorPlayersCache();
1233
			// route_cache tells NPC's where they can trade
1234
			$this->db->write('DELETE FROM route_cache WHERE game_id=' . $this->db->escapeNumber($this->getGameID()));
1235
		}
1236
1237
		// If any fields in the `port` table have changed, update table
1238
		if ($this->hasChanged) {
1239
			if ($this->isNew === false) {
1240
				$this->db->write('UPDATE port SET experience = ' . $this->db->escapeNumber($this->getExperience()) .
1241
								', shields = ' . $this->db->escapeNumber($this->getShields()) .
1242
								', armour = ' . $this->db->escapeNumber($this->getArmour()) .
1243
								', combat_drones = ' . $this->db->escapeNumber($this->getCDs()) .
1244
								', level = ' . $this->db->escapeNumber($this->getLevel()) .
1245
								', credits = ' . $this->db->escapeNumber($this->getCredits()) .
1246
								', upgrade = ' . $this->db->escapeNumber($this->getUpgrade()) .
1247
								', reinforce_time = ' . $this->db->escapeNumber($this->getReinforceTime()) .
1248
								', attack_started = ' . $this->db->escapeNumber($this->getAttackStarted()) .
1249
								', race_id = ' . $this->db->escapeNumber($this->getRaceID()) . '
1250
								WHERE ' . $this->SQL);
1251
			} else {
1252
				$this->db->insert('port', [
1253
					'game_id' => $this->db->escapeNumber($this->getGameID()),
1254
					'sector_id' => $this->db->escapeNumber($this->getSectorID()),
1255
					'experience' => $this->db->escapeNumber($this->getExperience()),
1256
					'shields' => $this->db->escapeNumber($this->getShields()),
1257
					'armour' => $this->db->escapeNumber($this->getArmour()),
1258
					'combat_drones' => $this->db->escapeNumber($this->getCDs()),
1259
					'level' => $this->db->escapeNumber($this->getLevel()),
1260
					'credits' => $this->db->escapeNumber($this->getCredits()),
1261
					'upgrade' => $this->db->escapeNumber($this->getUpgrade()),
1262
					'reinforce_time' => $this->db->escapeNumber($this->getReinforceTime()),
1263
					'attack_started' => $this->db->escapeNumber($this->getAttackStarted()),
1264
					'race_id' => $this->db->escapeNumber($this->getRaceID()),
1265
				]);
1266
				$this->isNew = false;
1267
			}
1268
			$this->hasChanged = false;
1269
		}
1270
1271
		// Update the port good amounts if they have been changed
1272
		// (Note: `restockGoods` alone does not trigger this)
1273
		foreach ($this->goodAmountsChanged as $goodID => $doUpdate) {
1274
			if (!$doUpdate) {
1275
				continue;
1276
			}
1277
			$amount = $this->getGoodAmount($goodID);
1278
			$this->db->write('UPDATE port_has_goods SET amount = ' . $this->db->escapeNumber($amount) . ', last_update = ' . $this->db->escapeNumber(Epoch::time()) . ' WHERE ' . $this->SQL . ' AND good_id = ' . $this->db->escapeNumber($goodID));
1279
			unset($this->goodAmountsChanged[$goodID]);
1280
		}
1281
1282
		// Handle any goods that were added or removed
1283
		foreach ($this->goodTransactionsChanged as $goodID => $status) {
1284
			if ($status === true) {
1285
				// add the good
1286
				$this->db->replace('port_has_goods', [
1287
					'game_id' => $this->db->escapeNumber($this->getGameID()),
1288
					'sector_id' => $this->db->escapeNumber($this->getSectorID()),
1289
					'good_id' => $this->db->escapeNumber($goodID),
1290
					'transaction_type' => $this->db->escapeString($this->getGoodTransaction($goodID)->value),
1291
					'amount' => $this->db->escapeNumber($this->getGoodAmount($goodID)),
1292
					'last_update' => $this->db->escapeNumber(Epoch::time()),
1293
				]);
1294
			} else {
1295
				// remove the good
1296
				$this->db->write('DELETE FROM port_has_goods WHERE ' . $this->SQL . ' AND good_id=' . $this->db->escapeNumber($goodID) . ';');
1297
			}
1298
			unset($this->goodTransactionsChanged[$goodID]);
1299
		}
1300
1301
	}
1302
1303
	/**
1304
	 * @param array<AbstractSmrPlayer> $targetPlayers
1305
	 * @return array<string, mixed>
1306
	 */
1307
	public function shootPlayers(array $targetPlayers): array {
1308
		$results = ['Port' => $this, 'TotalDamage' => 0, 'TotalDamagePerTargetPlayer' => []];
1309
		foreach ($targetPlayers as $targetPlayer) {
1310
			$results['TotalDamagePerTargetPlayer'][$targetPlayer->getAccountID()] = 0;
1311
			$results['TotalShotsPerTargetPlayer'][$targetPlayer->getAccountID()] = 0;
1312
		}
1313
		if ($this->isDestroyed()) {
1314
			$results['DeadBeforeShot'] = true;
1315
			return $results;
1316
		}
1317
		$results['DeadBeforeShot'] = false;
1318
		$weapons = $this->getWeapons();
1319
		foreach ($weapons as $orderID => $weapon) {
1320
			do {
1321
				$targetPlayer = array_rand_value($targetPlayers);
0 ignored issues
show
Bug introduced by
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

1321
				$targetPlayer = /** @scrutinizer ignore-call */ array_rand_value($targetPlayers);
Loading history...
1322
			} while ($results['TotalShotsPerTargetPlayer'][$targetPlayer->getAccountID()] > min($results['TotalShotsPerTargetPlayer']));
1323
			$results['Weapons'][$orderID] = $weapon->shootPlayerAsPort($this, $targetPlayer);
1324
			$results['TotalShotsPerTargetPlayer'][$targetPlayer->getAccountID()]++;
1325
			if ($results['Weapons'][$orderID]['Hit']) {
1326
				$results['TotalDamage'] += $results['Weapons'][$orderID]['ActualDamage']['TotalDamage'];
1327
				$results['TotalDamagePerTargetPlayer'][$targetPlayer->getAccountID()] += $results['Weapons'][$orderID]['ActualDamage']['TotalDamage'];
1328
			}
1329
		}
1330
		if ($this->hasCDs()) {
1331
			$thisCDs = new SmrCombatDrones($this->getCDs(), true);
1332
			$results['Drones'] = $thisCDs->shootPlayerAsPort($this, array_rand_value($targetPlayers));
1333
			$results['TotalDamage'] += $results['Drones']['ActualDamage']['TotalDamage'];
1334
			$results['TotalDamagePerTargetPlayer'][$results['Drones']['TargetPlayer']->getAccountID()] += $results['Drones']['ActualDamage']['TotalDamage'];
1335
		}
1336
		return $results;
1337
	}
1338
1339
	/**
1340
	 * @param array<string, int|bool> $damage
1341
	 * @return array<string, int|bool>
1342
	 */
1343
	public function takeDamage(array $damage): array {
1344
		$alreadyDead = $this->isDestroyed();
1345
		$shieldDamage = 0;
1346
		$cdDamage = 0;
1347
		$armourDamage = 0;
1348
		if (!$alreadyDead) {
1349
			$shieldDamage = $this->takeDamageToShields($damage['Shield']);
0 ignored issues
show
Bug introduced by
It seems like $damage['Shield'] can also be of type boolean; however, parameter $damage of AbstractSmrPort::takeDamageToShields() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

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

1349
			$shieldDamage = $this->takeDamageToShields(/** @scrutinizer ignore-type */ $damage['Shield']);
Loading history...
1350
			if ($shieldDamage == 0 || $damage['Rollover']) {
1351
				$cdMaxDamage = $damage['Armour'] - $shieldDamage;
1352
				if ($shieldDamage == 0 && $this->hasShields()) {
1353
					$cdMaxDamage = IFloor($cdMaxDamage * DRONES_BEHIND_SHIELDS_DAMAGE_PERCENT);
0 ignored issues
show
Bug introduced by
The function IFloor 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

1353
					$cdMaxDamage = /** @scrutinizer ignore-call */ IFloor($cdMaxDamage * DRONES_BEHIND_SHIELDS_DAMAGE_PERCENT);
Loading history...
1354
				}
1355
				$cdDamage = $this->takeDamageToCDs($cdMaxDamage);
1356
				if (!$this->hasShields() && ($cdDamage == 0 || $damage['Rollover'])) {
1357
					$armourMaxDamage = $damage['Armour'] - $shieldDamage - $cdDamage;
1358
					$armourDamage = $this->takeDamageToArmour($armourMaxDamage);
1359
				}
1360
			}
1361
		}
1362
1363
		$return = [
1364
						'KillingShot' => !$alreadyDead && $this->isDestroyed(),
1365
						'TargetAlreadyDead' => $alreadyDead,
1366
						'Shield' => $shieldDamage,
1367
						'CDs' => $cdDamage,
1368
						'NumCDs' => $cdDamage / CD_ARMOUR,
1369
						'HasCDs' => $this->hasCDs(),
1370
						'Armour' => $armourDamage,
1371
						'TotalDamage' => $shieldDamage + $cdDamage + $armourDamage,
1372
		];
1373
		return $return;
1374
	}
1375
1376
	protected function takeDamageToShields(int $damage): int {
1377
		$actualDamage = min($this->getShields(), $damage);
1378
		$this->decreaseShields($actualDamage);
1379
		return $actualDamage;
1380
	}
1381
1382
	protected function takeDamageToCDs(int $damage): int {
1383
		$actualDamage = min($this->getCDs(), IFloor($damage / CD_ARMOUR));
0 ignored issues
show
Bug introduced by
The function IFloor 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

1383
		$actualDamage = min($this->getCDs(), /** @scrutinizer ignore-call */ IFloor($damage / CD_ARMOUR));
Loading history...
1384
		$this->decreaseCDs($actualDamage);
1385
		return $actualDamage * CD_ARMOUR;
1386
	}
1387
1388
	protected function takeDamageToArmour(int $damage): int {
1389
		$actualDamage = min($this->getArmour(), IFloor($damage));
0 ignored issues
show
Bug introduced by
The function IFloor 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

1389
		$actualDamage = min($this->getArmour(), /** @scrutinizer ignore-call */ IFloor($damage));
Loading history...
1390
		$this->decreaseArmour($actualDamage);
1391
		return $actualDamage;
1392
	}
1393
1394
	/**
1395
	 * @return array<SmrPlayer>
1396
	 */
1397
	public function getAttackersToCredit(): array {
1398
		//get all players involved for HoF
1399
		$attackers = [];
1400
		$dbResult = $this->db->read('SELECT player.* FROM player_attacks_port JOIN player USING (game_id, account_id) WHERE game_id = ' . $this->db->escapeNumber($this->gameID) . ' AND player_attacks_port.sector_id = ' . $this->db->escapeNumber($this->sectorID) . ' AND time > ' . $this->db->escapeNumber(Epoch::time() - self::TIME_TO_CREDIT_RAID));
1401
		foreach ($dbResult->records() as $dbRecord) {
1402
			$attackers[] = SmrPlayer::getPlayer($dbRecord->getInt('account_id'), $this->getGameID(), false, $dbRecord);
1403
		}
1404
		return $attackers;
1405
	}
1406
1407
	protected function creditCurrentAttackersForKill(): void {
1408
		//get all players involved for HoF
1409
		$attackers = $this->getAttackersToCredit();
1410
		foreach ($attackers as $attacker) {
1411
			$attacker->increaseHOF($this->level, ['Combat', 'Port', 'Levels Raided'], HOF_PUBLIC);
1412
			$attacker->increaseHOF(1, ['Combat', 'Port', 'Total Raided'], HOF_PUBLIC);
1413
		}
1414
	}
1415
1416
	protected function payout(AbstractSmrPlayer $killer, int $credits, string $payoutType): bool {
1417
		if ($this->getCredits() == 0) {
1418
			return false;
1419
		}
1420
		$killer->increaseCredits($credits);
1421
		$killer->increaseHOF($credits, ['Combat', 'Port', 'Money', 'Gained'], HOF_PUBLIC);
1422
		$attackers = $this->getAttackersToCredit();
1423
		foreach ($attackers as $attacker) {
1424
			$attacker->increaseHOF(1, ['Combat', 'Port', $payoutType], HOF_PUBLIC);
1425
		}
1426
		$this->setCredits(0);
1427
		return true;
1428
	}
1429
1430
	/**
1431
	 * Get a reduced fraction of the credits stored in the port for razing
1432
	 * after a successful port raid.
1433
	 */
1434
	public function razePort(AbstractSmrPlayer $killer): int {
1435
		$credits = IFloor($this->getCredits() * self::BASE_PAYOUT * self::RAZE_PAYOUT);
0 ignored issues
show
Bug introduced by
The function IFloor 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

1435
		$credits = /** @scrutinizer ignore-call */ IFloor($this->getCredits() * self::BASE_PAYOUT * self::RAZE_PAYOUT);
Loading history...
1436
		if ($this->payout($killer, $credits, 'Razed')) {
1437
			$this->doDowngrade();
1438
		}
1439
		return $credits;
1440
	}
1441
1442
	/**
1443
	 * Get a fraction of the credits stored in the port for looting after a
1444
	 * successful port raid.
1445
	 */
1446
	public function lootPort(AbstractSmrPlayer $killer): int {
1447
		$credits = IFloor($this->getCredits() * self::BASE_PAYOUT);
0 ignored issues
show
Bug introduced by
The function IFloor 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

1447
		$credits = /** @scrutinizer ignore-call */ IFloor($this->getCredits() * self::BASE_PAYOUT);
Loading history...
1448
		$this->payout($killer, $credits, 'Looted');
1449
		return $credits;
1450
	}
1451
1452
	/**
1453
	 * @return array<string, mixed>
1454
	 */
1455
	public function killPortByPlayer(AbstractSmrPlayer $killer): array {
1456
		// Port is destroyed, so empty the port of all trade goods
1457
		foreach ($this->getAllGoodIDs() as $goodID) {
1458
			$this->setGoodAmount($goodID, 0);
1459
		}
1460
1461
		$this->creditCurrentAttackersForKill();
1462
1463
		// News Entry
1464
		$news = $this->getDisplayName() . ' has been successfully raided by ';
1465
		if ($killer->hasAlliance()) {
1466
			$news .= 'the members of <span class="yellow">' . $killer->getAllianceBBLink() . '</span>';
1467
		} else {
1468
			$news .= $killer->getBBLink();
1469
		}
1470
		$this->db->insert('news', [
1471
			'game_id' => $this->db->escapeNumber($this->getGameID()),
1472
			'time' => $this->db->escapeNumber(Epoch::time()),
1473
			'news_message' => $this->db->escapeString($news),
1474
			'killer_id' => $this->db->escapeNumber($killer->getAccountID()),
1475
			'killer_alliance' => $this->db->escapeNumber($killer->getAllianceID()),
1476
			'dead_id' => $this->db->escapeNumber(ACCOUNT_ID_PORT),
1477
		]);
1478
1479
		// Killer gets a relations change and a bounty if port is taken
1480
		$killerBounty = $killer->getExperience() * $this->getLevel();
1481
		$killer->increaseCurrentBountyAmount(BountyType::HQ, $killerBounty);
1482
		$killer->increaseHOF($killerBounty, ['Combat', 'Port', 'Bounties', 'Gained'], HOF_PUBLIC);
1483
1484
		$killer->decreaseRelations(self::KILLER_RELATIONS_LOSS, $this->getRaceID());
1485
		$killer->increaseHOF(self::KILLER_RELATIONS_LOSS, ['Combat', 'Port', 'Relation', 'Loss'], HOF_PUBLIC);
1486
1487
		return [];
1488
	}
1489
1490
	public function hasX(mixed $x): bool {
1491
		if (is_array($x) && $x['Type'] == 'Good') { // instanceof Good) - No Good class yet, so array is the best we can do
1492
			if (isset($x['ID'])) {
1493
				return $this->hasGood($x['ID'], $x['TransactionType'] ?? null);
1494
			}
1495
		}
1496
		return false;
1497
	}
1498
1499
}
1500