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
Push — main ( 2c9538...02418a )
by Dan
38s queued 18s
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