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

AbstractSmrPort::setPortGoods()   B
last analyzed

Complexity

Conditions 8
Paths 28

Size

Total Lines 26
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

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

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

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

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

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

1061
		return /** @scrutinizer ignore-call */ IRound($base * $scale * $distFactor * $supplyFactor * $relationsFactor);
Loading history...
1062
	}
1063
1064
	public function getOfferPrice(int $idealPrice, int $relations, TransactionType $transactionType): int {
1065
		$relations = min(1000, $relations); // no effect for higher relations
1066
		$relationsEffect = (2 * $relations + 8000) / 10000; // [0.75-1]
1067
1068
		return match ($transactionType) {
1069
			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

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

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

1325
				$targetPlayer = /** @scrutinizer ignore-call */ array_rand_value($targetPlayers);
Loading history...
1326
			} while ($results['TotalShotsPerTargetPlayer'][$targetPlayer->getAccountID()] > min($results['TotalShotsPerTargetPlayer']));
1327
			$results['Weapons'][$orderID] = $weapon->shootPlayerAsPort($this, $targetPlayer);
1328
			$results['TotalShotsPerTargetPlayer'][$targetPlayer->getAccountID()]++;
1329
			if ($results['Weapons'][$orderID]['Hit']) {
1330
				$results['TotalDamage'] += $results['Weapons'][$orderID]['ActualDamage']['TotalDamage'];
1331
				$results['TotalDamagePerTargetPlayer'][$targetPlayer->getAccountID()] += $results['Weapons'][$orderID]['ActualDamage']['TotalDamage'];
1332
			}
1333
		}
1334
		if ($this->hasCDs()) {
1335
			$thisCDs = new SmrCombatDrones($this->getCDs(), true);
1336
			$results['Drones'] = $thisCDs->shootPlayerAsPort($this, array_rand_value($targetPlayers));
1337
			$results['TotalDamage'] += $results['Drones']['ActualDamage']['TotalDamage'];
1338
			$results['TotalDamagePerTargetPlayer'][$results['Drones']['TargetPlayer']->getAccountID()] += $results['Drones']['ActualDamage']['TotalDamage'];
1339
		}
1340
		return $results;
1341
	}
1342
1343
	/**
1344
	 * @param WeaponDamageData $damage
0 ignored issues
show
Bug introduced by
The type WeaponDamageData was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
1345
	 * @return array<string, int|bool>
1346
	 */
1347
	public function takeDamage(array $damage): array {
1348
		$alreadyDead = $this->isDestroyed();
1349
		$shieldDamage = 0;
1350
		$cdDamage = 0;
1351
		$armourDamage = 0;
1352
		if (!$alreadyDead) {
1353
			$shieldDamage = $this->takeDamageToShields($damage['Shield']);
1354
			if ($shieldDamage == 0 || $damage['Rollover']) {
1355
				$cdMaxDamage = $damage['Armour'] - $shieldDamage;
1356
				if ($shieldDamage == 0 && $this->hasShields()) {
1357
					$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

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

1386
		$actualDamage = min($this->getCDs(), /** @scrutinizer ignore-call */ IFloor($damage / CD_ARMOUR));
Loading history...
1387
		$this->decreaseCDs($actualDamage);
1388
		return $actualDamage * CD_ARMOUR;
1389
	}
1390
1391
	protected function takeDamageToArmour(int $damage): int {
1392
		$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

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

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

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