Scrutinizer GitHub App not installed

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

Install GitHub App

Failed Conditions
Push — main ( d9cfb9...10f5c7 )
by Dan
32s queued 21s
created

src/lib/Default/AbstractSmrPort.php (4 issues)

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);
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()));
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);
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)) {
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
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...
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
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);
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());
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);
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))),
1082
			TransactionType::Sell => min($idealPrice, ICeil($idealPrice * $relationsEffect)),
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)
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)
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);
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
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);
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));
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));
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);
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);
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