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 (#1498)
by Dan
04:47
created

AbstractSmrPort::getGoodTransactions()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 2
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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

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

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

384
		$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

384
		$this->setGoodAmount($good['ID'], $this->getGoodAmount(/** @scrutinizer ignore-type */ $good['ID']) - $amount);
Loading history...
385
		if ($doRefresh === true) {
386
			//get id of goods to replenish
387
			$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

387
			$this->refreshGoods(/** @scrutinizer ignore-type */ $good['Class'], $amount);
Loading history...
388
		}
389
	}
390
391
	public function increaseGoodAmount(int $goodID, int $amount): void {
392
		$this->setGoodAmount($goodID, $this->getGoodAmount($goodID) + $amount);
393
	}
394
395
	public function decreaseGoodAmount(int $goodID, int $amount): void {
396
		$this->setGoodAmount($goodID, $this->getGoodAmount($goodID) - $amount);
397
	}
398
399
	/**
400
	 * Adds extra stock to goods in the tier above a good that was traded
401
	 */
402
	protected function refreshGoods(int $classTraded, int $amountTraded): void {
403
		$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

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

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

1073
		return /** @scrutinizer ignore-call */ IRound($base * $scale * $distFactor * $supplyFactor * $relationsFactor);
Loading history...
1074
	}
1075
1076
	public function getOfferPrice(int $idealPrice, int $relations, TransactionType $transactionType): int {
1077
		$relations = min(1000, $relations); // no effect for higher relations
1078
		$relationsEffect = (2 * $relations + 8000) / 10000; // [0.75-1]
1079
1080
		return match ($transactionType) {
1081
			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

1081
			TransactionType::Buy => max($idealPrice, /** @scrutinizer ignore-call */ IFloor($idealPrice * (2 - $relationsEffect))),
Loading history...
1082
			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

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

1330
				$targetPlayer = /** @scrutinizer ignore-call */ array_rand_value($targetPlayers);
Loading history...
1331
			} while ($results['TotalShotsPerTargetPlayer'][$targetPlayer->getAccountID()] > min($results['TotalShotsPerTargetPlayer']));
1332
			$results['Weapons'][$orderID] = $weapon->shootPlayerAsPort($this, $targetPlayer);
1333
			$results['TotalShotsPerTargetPlayer'][$targetPlayer->getAccountID()]++;
1334
			if ($results['Weapons'][$orderID]['Hit']) {
1335
				$results['TotalDamage'] += $results['Weapons'][$orderID]['ActualDamage']['TotalDamage'];
1336
				$results['TotalDamagePerTargetPlayer'][$targetPlayer->getAccountID()] += $results['Weapons'][$orderID]['ActualDamage']['TotalDamage'];
1337
			}
1338
		}
1339
		if ($this->hasCDs()) {
1340
			$thisCDs = new SmrCombatDrones($this->getCDs(), true);
1341
			$results['Drones'] = $thisCDs->shootPlayerAsPort($this, array_rand_value($targetPlayers));
1342
			$results['TotalDamage'] += $results['Drones']['ActualDamage']['TotalDamage'];
1343
			$results['TotalDamagePerTargetPlayer'][$results['Drones']['TargetPlayer']->getAccountID()] += $results['Drones']['ActualDamage']['TotalDamage'];
1344
		}
1345
		return $results;
1346
	}
1347
1348
	/**
1349
	 * @param array<string, int|bool> $damage
1350
	 * @return array<string, int|bool>
1351
	 */
1352
	public function takeDamage(array $damage): array {
1353
		$alreadyDead = $this->isDestroyed();
1354
		$shieldDamage = 0;
1355
		$cdDamage = 0;
1356
		$armourDamage = 0;
1357
		if (!$alreadyDead) {
1358
			$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

1358
			$shieldDamage = $this->takeDamageToShields(/** @scrutinizer ignore-type */ $damage['Shield']);
Loading history...
1359
			if ($shieldDamage == 0 || $damage['Rollover']) {
1360
				$cdMaxDamage = $damage['Armour'] - $shieldDamage;
1361
				if ($shieldDamage == 0 && $this->hasShields()) {
1362
					$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

1362
					$cdMaxDamage = /** @scrutinizer ignore-call */ IFloor($cdMaxDamage * DRONES_BEHIND_SHIELDS_DAMAGE_PERCENT);
Loading history...
1363
				}
1364
				$cdDamage = $this->takeDamageToCDs($cdMaxDamage);
1365
				if (!$this->hasShields() && ($cdDamage == 0 || $damage['Rollover'])) {
1366
					$armourMaxDamage = $damage['Armour'] - $shieldDamage - $cdDamage;
1367
					$armourDamage = $this->takeDamageToArmour($armourMaxDamage);
1368
				}
1369
			}
1370
		}
1371
1372
		$return = [
1373
						'KillingShot' => !$alreadyDead && $this->isDestroyed(),
1374
						'TargetAlreadyDead' => $alreadyDead,
1375
						'Shield' => $shieldDamage,
1376
						'CDs' => $cdDamage,
1377
						'NumCDs' => $cdDamage / CD_ARMOUR,
1378
						'HasCDs' => $this->hasCDs(),
1379
						'Armour' => $armourDamage,
1380
						'TotalDamage' => $shieldDamage + $cdDamage + $armourDamage,
1381
		];
1382
		return $return;
1383
	}
1384
1385
	protected function takeDamageToShields(int $damage): int {
1386
		$actualDamage = min($this->getShields(), $damage);
1387
		$this->decreaseShields($actualDamage);
1388
		return $actualDamage;
1389
	}
1390
1391
	protected function takeDamageToCDs(int $damage): int {
1392
		$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

1392
		$actualDamage = min($this->getCDs(), /** @scrutinizer ignore-call */ IFloor($damage / CD_ARMOUR));
Loading history...
1393
		$this->decreaseCDs($actualDamage);
1394
		return $actualDamage * CD_ARMOUR;
1395
	}
1396
1397
	protected function takeDamageToArmour(int $damage): int {
1398
		$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

1398
		$actualDamage = min($this->getArmour(), /** @scrutinizer ignore-call */ IFloor($damage));
Loading history...
1399
		$this->decreaseArmour($actualDamage);
1400
		return $actualDamage;
1401
	}
1402
1403
	/**
1404
	 * @return array<SmrPlayer>
1405
	 */
1406
	public function getAttackersToCredit(): array {
1407
		//get all players involved for HoF
1408
		$attackers = [];
1409
		$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));
1410
		foreach ($dbResult->records() as $dbRecord) {
1411
			$attackers[] = SmrPlayer::getPlayer($dbRecord->getInt('account_id'), $this->getGameID(), false, $dbRecord);
1412
		}
1413
		return $attackers;
1414
	}
1415
1416
	protected function creditCurrentAttackersForKill(): void {
1417
		//get all players involved for HoF
1418
		$attackers = $this->getAttackersToCredit();
1419
		foreach ($attackers as $attacker) {
1420
			$attacker->increaseHOF($this->level, ['Combat', 'Port', 'Levels Raided'], HOF_PUBLIC);
1421
			$attacker->increaseHOF(1, ['Combat', 'Port', 'Total Raided'], HOF_PUBLIC);
1422
		}
1423
	}
1424
1425
	protected function payout(AbstractSmrPlayer $killer, int $credits, string $payoutType): bool {
1426
		if ($this->getCredits() == 0) {
1427
			return false;
1428
		}
1429
		$killer->increaseCredits($credits);
1430
		$killer->increaseHOF($credits, ['Combat', 'Port', 'Money', 'Gained'], HOF_PUBLIC);
1431
		$attackers = $this->getAttackersToCredit();
1432
		foreach ($attackers as $attacker) {
1433
			$attacker->increaseHOF(1, ['Combat', 'Port', $payoutType], HOF_PUBLIC);
1434
		}
1435
		$this->setCredits(0);
1436
		return true;
1437
	}
1438
1439
	/**
1440
	 * Get a reduced fraction of the credits stored in the port for razing
1441
	 * after a successful port raid.
1442
	 */
1443
	public function razePort(AbstractSmrPlayer $killer): int {
1444
		$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

1444
		$credits = /** @scrutinizer ignore-call */ IFloor($this->getCredits() * self::BASE_PAYOUT * self::RAZE_PAYOUT);
Loading history...
1445
		if ($this->payout($killer, $credits, 'Razed')) {
1446
			$this->doDowngrade();
1447
		}
1448
		return $credits;
1449
	}
1450
1451
	/**
1452
	 * Get a fraction of the credits stored in the port for looting after a
1453
	 * successful port raid.
1454
	 */
1455
	public function lootPort(AbstractSmrPlayer $killer): int {
1456
		$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

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