Completed
Pull Request — master (#178)
by Vladimir
05:42 queued 02:52
created

Player::invalidateMatchFromCache()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 17
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 2

Importance

Changes 0
Metric Value
dl 0
loc 17
ccs 4
cts 4
cp 1
rs 9.4285
c 0
b 0
f 0
cc 2
eloc 10
nc 2
nop 1
crap 2
1
<?php
2
/**
3
 * This file contains functionality relating to a league player
4
 *
5
 * @package    BZiON\Models
6
 * @license    https://github.com/allejo/bzion/blob/master/LICENSE.md GNU General Public License Version 3
7
 */
8
9
use Carbon\Carbon;
10
use Symfony\Component\Security\Core\Util\SecureRandom;
11
use Symfony\Component\Security\Core\Util\StringUtils;
12
13
/**
14
 * A league player
15
 * @package    BZiON\Models
16
 */
17
class Player extends AvatarModel implements NamedModel, DuplexUrlInterface, EloInterface
18
{
19
    /**
20
     * These are built-in roles that cannot be deleted via the web interface so we will be storing these values as
21
     * constant variables. Hopefully, a user won't be silly enough to delete them manually from the database.
22
     *
23
     * @TODO Deprecate these and use the Role constants
24
     */
25
    const DEVELOPER    = Role::DEVELOPER;
26
    const ADMIN        = Role::ADMINISTRATOR;
27
    const COP          = Role::COP;
28
    const REFEREE      = Role::REFEREE;
29
    const S_ADMIN      = Role::SYSADMIN;
30
    const PLAYER       = Role::PLAYER;
31
    const PLAYER_NO_PM = Role::PLAYER_NO_PM;
32
33
    /**
34
     * The bzid of the player
35
     * @var int
36
     */
37
    protected $bzid;
38
39
    /**
40
     * The id of the player's team
41
     * @var int
42
     */
43
    protected $team;
44
45
    /**
46
     * The player's e-mail address
47
     * @var string
48
     */
49
    protected $email;
50
51
    /**
52
     * Whether the player has verified their e-mail address
53
     * @var bool
54
     */
55
    protected $verified;
56
57
    /**
58
     * What kind of events the player should be e-mailed about
59
     * @var string
60
     */
61
    protected $receives;
62
63
    /**
64
     * A confirmation code for the player's e-mail address verification
65
     * @var string
66
     */
67
    protected $confirmCode;
68
69
    /**
70
     * Whether the callsign of the player is outdated
71
     * @var bool
72
     */
73
    protected $outdated;
74
75
    /**
76
     * The player's profile description
77
     * @var string
78
     */
79
    protected $description;
80
81
    /**
82
     * The id of the player's country
83
     * @var int
84
     */
85
    protected $country;
86
87
    /**
88
     * The site theme this player has chosen
89
     * @var string
90
     */
91
    protected $theme;
92
93
    /**
94
     * Whether or not this player has opted-in for color blindness assistance.
95
     * @var bool
96
     */
97
    protected $color_blind_enabled;
98
99
    /**
100
     * The player's timezone PHP identifier, e.g. "Europe/Paris"
101
     * @var string
102
     */
103
    protected $timezone;
104
105
    /**
106
     * The date the player joined the site
107
     * @var TimeDate
108
     */
109
    protected $joined;
110
111
    /**
112
     * The date of the player's last login
113
     * @var TimeDate
114
     */
115
    protected $last_login;
116
117
    /**
118
     * The date of the player's last match
119
     * @var Match
120
     */
121
    protected $last_match;
122
123
    /**
124
     * The roles a player belongs to
125
     * @var Role[]
126
     */
127
    protected $roles;
128
129
    /**
130
     * The permissions a player has
131
     * @var Permission[]
132
     */
133
    protected $permissions;
134
135
    /**
136
     * A section for admins to write notes about players
137
     * @var string
138
     */
139
    protected $admin_notes;
140
141
    /**
142
     * The ban of the player, or null if the player is not banned
143
     * @var Ban|null
144
     */
145
    protected $ban;
146
147
    /**
148
     * Cached results for match summaries
149
     *
150
     * @var array
151
     */
152
    private $cachedMatchSummary;
153
154
    /**
155
     * The cached match count for a player
156
     *
157
     * @var int
158
     */
159
    private $cachedMatchCount = null;
160
161
    /**
162
     * The Elo for this player that has been explicitly set for this player from a database query. This value will take
163
     * precedence over having to build to an Elo season history.
164
     *
165
     * @var int
166
     */
167
    private $elo;
168
    private $eloSeason;
169
    private $eloSeasonHistory;
170
171
    private $matchActivity;
172
173
    /**
174
     * The name of the database table used for queries
175
     */
176
    const TABLE = "players";
177 76
178
    /**
179 76
     * The location where avatars will be stored
180 76
     */
181 76
    const AVATAR_LOCATION = "/web/assets/imgs/avatars/players/";
182 76
183 76
    const EDIT_PERMISSION = Permission::EDIT_USER;
184 76
    const SOFT_DELETE_PERMISSION = Permission::SOFT_DELETE_USER;
185 76
    const HARD_DELETE_PERMISSION = Permission::HARD_DELETE_USER;
186
187 76
    /**
188
     * {@inheritdoc}
189
     */
190 76
    protected function assignResult($player)
191
    {
192
        $this->bzid = $player['bzid'];
193
        $this->name = $player['username'];
194
        $this->alias = $player['alias'];
195 76
        $this->team = $player['team'];
196
        $this->status = $player['status'];
197 76
        $this->avatar = $player['avatar'];
198 76
        $this->country = $player['country'];
199 76
200 76
        if (array_key_exists('activity', $player)) {
201 76
            $this->matchActivity = ($player['activity'] != null) ? $player['activity'] : 0.0;
202 76
        }
203 76
204 76
        if (array_key_exists('elo', $player)) {
205 76
            $this->elo = $player['elo'];
206 76
        }
207 76
    }
208 76
209
    /**
210 76
     * {@inheritdoc}
211
     */
212 76
    protected function assignLazyResult($player)
213 76
    {
214
        $this->email = $player['email'];
215
        $this->verified = $player['verified'];
216
        $this->receives = $player['receives'];
217
        $this->confirmCode = $player['confirm_code'];
218
        $this->outdated = $player['outdated'];
219
        $this->description = $player['description'];
220
        $this->timezone = $player['timezone'];
221
        $this->joined = TimeDate::fromMysql($player['joined']);
222 76
        $this->last_login = TimeDate::fromMysql($player['last_login']);
223
        $this->last_match = Match::get($player['last_match']);
224 76
        $this->admin_notes = $player['admin_notes'];
225 1
        $this->ban = Ban::getBan($this->id);
226
        $this->color_blind_enabled = $player['color_blind_enabled'];
227
228 76
        $this->cachedMatchSummary = [];
229
230
        // Theme user options
231 76
        if (isset($player['theme'])) {
232 14
            $this->theme = $player['theme'];
233 14
        } else {
234
            $themes = Service::getSiteThemes();
235
            $this->theme = $themes[0]['slug'];
236
        }
237 76
238 76
        $this->updateUserPermissions();
239
    }
240 76
241
    /**
242
     * Add a player a new role
243
     *
244
     * @param Role|int $role_id The role ID to add a player to
245
     *
246
     * @return bool Whether the operation was successful or not
247
     */
248
    public function addRole($role_id)
249
    {
250
        if ($role_id instanceof Role) {
251
            $role_id = $role_id->getId();
252
        }
253
254
        $this->lazyLoad();
255
256
        // Make sure the player doesn't already have the role
257
        foreach ($this->roles as $playerRole) {
258 22
            if ($playerRole->getId() == $role_id) {
259
                return false;
260 22
            }
261
        }
262
263
        $status = $this->modifyRole($role_id, "add");
264
        $this->refresh();
265
266
        return $status;
267
    }
268 1
269
    /**
270 1
     * Get the notes admins have left about a player
271
     * @return string The notes
272
     */
273
    public function getAdminNotes()
274
    {
275
        $this->lazyLoad();
276
277
        return $this->admin_notes;
278
    }
279
280
    /**
281
     * Get the player's BZID
282
     * @return int The BZID
283
     */
284
    public function getBZID()
285
    {
286
        return $this->bzid;
287
    }
288
289
    /**
290
     * Get the country a player belongs to
291
     *
292
     * @return Country The country belongs to
293 30
     */
294
    public function getCountry()
295 30
    {
296 29
        return Country::get($this->country);
297
    }
298
299 30
    /**
300 29
     * Get the e-mail address of the player
301
     *
302
     * @return string The address
303 30
     */
304
    public function getEmailAddress()
305
    {
306
        $this->lazyLoad();
307
308
        return $this->email;
309
    }
310
311
    /**
312
     * Build a key that we'll use for caching season Elo data in this model
313 3
     *
314
     * @param  string|null $season The season to get
315 3
     * @param  int|null    $year   The year of the season to get
316
     *
317 3
     * @return string
318
     */
319
    private function buildSeasonKey(&$season, &$year)
320
    {
321
        if ($season === null) {
322
            $season = Season::getCurrentSeason();
323
        }
324
325
        if ($year === null) {
326
            $year = Carbon::now()->year;
327
        }
328
329
        return sprintf('%s-%s', $year, $season);
330
    }
331
332 3
    /**
333
     * Build a key to use for caching season Elo data in this model from a timestamp
334 3
     *
335 3
     * @param DateTime $timestamp
336
     *
337
     * @return string
338
     */
339 3
    private function buildSeasonKeyFromTimestamp(\DateTime $timestamp)
340 2
    {
341
        $seasonInfo = Season::getSeason($timestamp);
342
343
        return sprintf('%s-%s', $seasonInfo['year'], $seasonInfo['season']);
344
    }
345 3
346
    /**
347 3
     * Remove all Elo data for this model for matches occurring after the given match (inclusive)
348 1
     *
349
     * This function will not remove the Elo data for this match from the database. Ideally, this function should only
350
     * be called during Elo recalculation for this match.
351
     *
352
     * @internal
353 2
     *
354 2
     * @param Match $match
355
     *
356 2
     * @see Match::recalculateElo()
357
     */
358
    public function invalidateMatchFromCache(Match $match)
359 2
    {
360
        $seasonKey = $this->buildSeasonKeyFromTimestamp($match->getTimestamp());
361
        $seasonElo = null;
0 ignored issues
show
Unused Code introduced by
$seasonElo is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
362
363
        $this->getEloSeasonHistory();
364
365
        if (!isset($this->eloSeasonHistory[$seasonKey][$match->getId()])) {
366
            return;
367
        }
368
369 30
        $eloChangelogIndex = array_search($match->getId(), array_keys($this->eloSeasonHistory[$seasonKey]));
370
        $slicedChangeLog = array_slice($this->eloSeasonHistory[$seasonKey], 0, $eloChangelogIndex, true);
371 30
372
        $this->eloSeasonHistory[$seasonKey] = $slicedChangeLog;
373
        $this->eloSeason[$seasonKey] = end($slicedChangeLog)['elo'];
374 30
    }
375 29
376
    /**
377
     * Get the Elo changes for a player for a given season
378 30
     *
379
     * @param  string|null $season The season to get
380
     * @param  int|null    $year   The year of the season to get
381
     *
382
     * @return array
383
     */
384
    public function getEloSeasonHistory($season = null, $year = null)
385
    {
386
        $seasonKey = $this->buildSeasonKey($season, $year);
387
388
        // This season's already been cached
389
        if (isset($this->eloSeasonHistory[$seasonKey])) {
390
            return $this->eloSeasonHistory[$seasonKey];
391
        }
392 30
393
        $result = $this->db->query('
394 30
          SELECT
395 30
            elo_new AS elo,
396
            match_id AS `match`,
397 30
            MONTH(matches.timestamp) AS `month`,
398 30
            YEAR(matches.timestamp) AS `year`,
399 30
            DAY(matches.timestamp) AS `day`
400
          FROM
401
            player_elo
402 30
            LEFT JOIN matches ON player_elo.match_id = matches.id
403
          WHERE
404
            user_id = ? AND season_period = ? AND season_year = ?
405
          ORDER BY
406
            match_id ASC
407
        ', [ $this->getId(), $season, $year ]);
408
409
        $this->eloSeasonHistory[$seasonKey] = [[
410
            'elo' => 1200,
411
            'match' => null,
412
            'month' => Season::getCurrentSeasonRange($season)->getStartOfRange()->month,
413
            'year' => $year,
414
            'day' => 1
415 30
        ]] + array_combine(array_column($result, 'match'), $result);
416
417 30
        return $this->eloSeasonHistory[$seasonKey];
418 30
    }
419
420 30
    /**
421 29
     * Get the player's Elo for a season.
422
     *
423
     * With the default arguments, it will fetch the Elo for the current season.
424 30
     *
425
     * @param string|null $season The season we're looking for: winter, spring, summer, or fall
426 30
     * @param int|null    $year   The year of the season we're looking for
427 30
     *
428 30
     * @return int The player's Elo
429
     */
430
    public function getElo($season = null, $year = null)
431
    {
432
        // The Elo for this player has been forcefully set from a trusted database query, so just return that.
433 30
        if ($this->elo !== null) {
434
            return $this->elo;
435
        }
436
437
        $this->getEloSeasonHistory($season, $year);
438
        $seasonKey = $this->buildSeasonKey($season, $year);
439
440
        if (isset($this->eloSeason[$seasonKey])) {
441
            return $this->eloSeason[$seasonKey];
442
        }
443
444
        $season = &$this->eloSeasonHistory[$seasonKey];
445 30
446
        if (!empty($season)) {
447 30
            $elo = end($season);
448 30
            $this->eloSeason[$seasonKey] = ($elo !== false) ? $elo['elo'] : 1200;
449
        } else {
450
            $this->eloSeason[$seasonKey] = 1200;
451 30
        }
452 30
453
        return $this->eloSeason[$seasonKey];
454 30
    }
455
456 30
    /**
457 29
     * Adjust the Elo of a player for the current season based on a Match
458
     *
459 29
     * **Warning:** If $match is null, the Elo for the player will be modified but the value will not be persisted to
460
     * the database.
461 30
     *
462
     * @param int        $adjust The value to be added to the current ELO (negative to subtract)
463
     * @param Match|null $match  The match where this Elo change took place
464
     */
465
    public function adjustElo($adjust, Match $match = null)
466
    {
467
        $timestamp = ($match !== null) ? $match->getTimestamp() : (Carbon::now());
468
        $seasonInfo = Season::getSeason($timestamp);
469
470
        // Get the current Elo for the player, even if it's the default 1200. We need the value for adjusting
471
        $elo = $this->getElo($seasonInfo['season'], $seasonInfo['year']);
472
        $seasonKey = sprintf('%s-%s', $seasonInfo['year'], $seasonInfo['season']);
473
474
        $this->eloSeason[$seasonKey] += $adjust;
475
476
        if ($match !== null && $this->isValid()) {
477
            $this->eloSeasonHistory[$seasonKey][$match->getId()] = [
478
                'elo' => $this->eloSeason[$seasonKey],
479
                'match' => $match->getId(),
480
                'month' => $match->getTimestamp()->month,
481
                'year' => $match->getTimestamp()->year,
482
                'day' => null,
483
            ];
484
485
            $this->db->execute('
486
              INSERT INTO player_elo VALUES (?, ?, ?, ?, ?, ?)
487
            ', [ $this->getId(), $match->getId(), $seasonInfo['season'], $seasonInfo['year'], $elo, $this->eloSeason[$seasonKey] ]);
488
        }
489
    }
490
491
    /**
492
     * Returns whether the player has verified their e-mail address
493
     *
494
     * @return bool `true` for verified players
495
     */
496
    public function isVerified()
497
    {
498
        $this->lazyLoad();
499
500
        return $this->verified;
501
    }
502
503
    /**
504
     * Returns the confirmation code for the player's e-mail address verification
505
     *
506 1
     * @return string The player's confirmation code
507
     */
508 1
    public function getConfirmCode()
509
    {
510 1
        $this->lazyLoad();
511
512 1
        return $this->confirmCode;
513
    }
514
515
    /**
516
     * Returns what kind of events the player should be e-mailed about
517
     *
518
     * @return string The type of notifications
519
     */
520
    public function getReceives()
521
    {
522
        $this->lazyLoad();
523
524
        return $this->receives;
525
    }
526
527
    /**
528
     * Finds out whether the specified player wants and can receive an e-mail
529
     * message
530
     *
531
     * @param  string  $type
532
     * @return bool `true` if the player should be sent an e-mail
533
     */
534
    public function canReceive($type)
535
    {
536
        $this->lazyLoad();
537
538
        if (!$this->email || !$this->isVerified()) {
539
            // Unverified e-mail means the user will receive nothing
540
            return false;
541
        }
542
543
        if ($this->receives == 'everything') {
544
            return true;
545
        }
546
547
        return $this->receives == $type;
548
    }
549
550
    /**
551
     * Find out whether the specified confirmation code is correct
552
     *
553
     * This method protects against timing attacks
554
     *
555
     * @param  string $code The confirmation code to check
556
     * @return bool `true` for a correct e-mail verification code
557
     */
558
    public function isCorrectConfirmCode($code)
559
    {
560
        $this->lazyLoad();
561
562
        if ($this->confirmCode === null) {
563
            return false;
564
        }
565
566
        return StringUtils::equals($code, $this->confirmCode);
567
    }
568
569
    /**
570
     * Get the player's sanitized description
571
     * @return string The description
572
     */
573
    public function getDescription()
574
    {
575
        $this->lazyLoad();
576
577
        return $this->description;
578
    }
579
580
    /**
581
     * Get the joined date of the player
582
     * @return TimeDate The joined date of the player
583
     */
584
    public function getJoinedDate()
585
    {
586
        $this->lazyLoad();
587
588
        return $this->joined->copy();
589
    }
590
591
    /**
592
     * Get all of the known IPs used by the player
593
     *
594
     * @return string[][] An array containing IPs and hosts
595
     */
596
    public function getKnownIPs()
597
    {
598
        return $this->db->query(
599
            'SELECT DISTINCT ip, host FROM visits WHERE player = ? GROUP BY ip, host ORDER BY MAX(timestamp) DESC LIMIT 10',
600
            array($this->getId())
601
        );
602
    }
603
604
    /**
605
     * Get the last login for a player
606
     * @return TimeDate The date of the last login
607
     */
608
    public function getLastLogin()
609
    {
610
        $this->lazyLoad();
611 23
612
        return $this->last_login->copy();
613 23
    }
614
615
    /**
616
     * Get the last match
617
     * @return Match
618
     */
619
    public function getLastMatch()
620 1
    {
621
        $this->lazyLoad();
622 1
623
        return $this->last_match;
624 1
    }
625
626
    /**
627
     * Get all of the callsigns a player has used to log in to the website
628
     * @return string[] An array containing all of the past callsigns recorded for a player
629
     */
630
    public function getPastCallsigns()
631
    {
632
        return self::fetchIds("WHERE player = ?", array($this->id), "past_callsigns", "username");
633
    }
634
635
    /**
636
     * Get the player's team
637
     * @return Team The object representing the team
638
     */
639
    public function getTeam()
640
    {
641 76
        return Team::get($this->team);
642
    }
643 76
644 76
    /**
645
     * Get the player's timezone PHP identifier (example: "Europe/Paris")
646 76
     * @return string The timezone
647 76
     */
648
    public function getTimezone()
649 76
    {
650
        $this->lazyLoad();
651
652
        return ($this->timezone) ?: date_default_timezone_get();
653
    }
654
655
    /**
656
     * Get the roles of the player
657
     * @return Role[]
658 2
     */
659
    public function getRoles()
660 2
    {
661 1
        $this->lazyLoad();
662
663
        return $this->roles;
664 2
    }
665
666 2
    /**
667
     * Rebuild the list of permissions a user has been granted
668
     */
669
    private function updateUserPermissions()
670
    {
671
        $this->roles = Role::getRoles($this->id);
0 ignored issues
show
Documentation Bug introduced by
It seems like \Role::getRoles($this->id) of type array<integer,object<Model>> is incompatible with the declared type array<integer,object<Role>> of property $roles.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
672
        $this->permissions = array();
673
674
        foreach ($this->roles as $role) {
675
            $this->permissions = array_merge($this->permissions, $role->getPerms());
0 ignored issues
show
Bug introduced by
It seems like you code against a specific sub-type and not the parent class Model as the method getPerms() does only exist in the following sub-classes of Model: Permission, Role. Maybe you want to instanceof check for one of these explicitly?

Let’s take a look at an example:

abstract class User
{
    /** @return string */
    abstract public function getPassword();
}

class MyUser extends User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different sub-classes of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the parent class:

    abstract class User
    {
        /** @return string */
        abstract public function getPassword();
    
        /** @return string */
        abstract public function getDisplayName();
    }
    
Loading history...
Documentation Bug introduced by
It seems like array_merge($this->permi...ons, $role->getPerms()) of type array is incompatible with the declared type array<integer,object<Permission>> of property $permissions.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
676
        }
677
    }
678
679
    /**
680
     * Check if a player has a specific permission
681
     *
682
     * @param string|null $permission The permission to check for
683
     *
684
     * @return bool Whether or not the player has the permission
685
     */
686
    public function hasPermission($permission)
687
    {
688
        if ($permission === null) {
689
            return false;
690
        }
691
692
        $this->lazyLoad();
693
694
        return isset($this->permissions[$permission]);
695
    }
696
697
    /**
698
     * Check whether or not a player been in a match or has logged on in the specified amount of time to be considered
699
     * active
700
     *
701
     * @return bool True if the player has been active
702
     */
703
    public function hasBeenActive()
704
    {
705
        $this->lazyLoad();
706
707
        $interval  = Service::getParameter('bzion.miscellaneous.active_interval');
708
        $lastLogin = $this->last_login->copy()->modify($interval);
709
710
        $hasBeenActive = (TimeDate::now() <= $lastLogin);
711
712
        if ($this->last_match->isValid()) {
713
            $lastMatch = $this->last_match->getTimestamp()->copy()->modify($interval);
714
            $hasBeenActive = ($hasBeenActive || TimeDate::now() <= $lastMatch);
715
        }
716
717
        return $hasBeenActive;
718
    }
719
720
    /**
721
     * Check whether the callsign of the player is outdated
722 1
     *
723
     * Returns true if this player has probably changed their callsign, making
724 1
     * the current username stored in the database obsolete
725
     *
726
     * @return bool Whether or not the player is disabled
727
     */
728
    public function isOutdated()
729
    {
730
        $this->lazyLoad();
731
732 55
        return $this->outdated;
733
    }
734 55
735
    /**
736
     * Check if a player's account has been disabled
737
     *
738
     * @return bool Whether or not the player is disabled
739
     */
740 1
    public function isDisabled()
741
    {
742 1
        return $this->status == "disabled";
743
    }
744
745
    /**
746 1
     * Check if everyone can log in as this user on a test environment
747
     *
748
     * @return bool
749
     */
750
    public function isTestUser()
751
    {
752
        return $this->status == "test";
753
    }
754
755
    /**
756
     * Check if a player is teamless
757
     *
758
     * @return bool True if the player is teamless
759
     */
760
    public function isTeamless()
761
    {
762
        return empty($this->team);
763
    }
764
765
    /**
766 2
     * Mark a player's account as banned
767
     *
768 2
     * @deprecated The players table shouldn't have any indicators of banned status, the Bans table is the authoritative source
769
     */
770
    public function markAsBanned()
771
    {
772
        return;
0 ignored issues
show
Coding Style introduced by
Empty return statement not required here
Loading history...
773
    }
774
775
    /**
776
     * Mark a player's account as unbanned
777
     *
778
     * @deprecated The players table shouldn't have any indicators of banned status, the Bans table is the authoritative source
779
     */
780
    public function markAsUnbanned()
781
    {
782
        return;
0 ignored issues
show
Coding Style introduced by
Empty return statement not required here
Loading history...
783
    }
784
785
    /**
786
     * Find out if a player is hard banned
787
     *
788
     * @return bool
789
     */
790
    public function isBanned()
791
    {
792
        $ban = Ban::getBan($this->id);
793
794
        return ($ban !== null && !$ban->isSoftBan());
795
    }
796
797
    /**
798
     * Get the ban of the player
799
     *
800
     * This method performs a load of all the lazy parameters of the Player
801
     *
802
     * @return Ban|null The current ban of the player, or null if the player is
803
     *                  is not banned
804
     */
805
    public function getBan()
806
    {
807
        $this->lazyLoad();
808
809
        return $this->ban;
810
    }
811
812
    /**
813
     * Remove a player from a role
814
     *
815
     * @param int $role_id The role ID to add or remove
816
     *
817
     * @return bool Whether the operation was successful or not
818
     */
819
    public function removeRole($role_id)
820
    {
821
        $status = $this->modifyRole($role_id, "remove");
822
        $this->refresh();
823
824
        return $status;
825
    }
826
827
    /**
828
     * Set the player's email address and reset their verification status
829
     * @param string $email The address
830
     */
831
    public function setEmailAddress($email)
832
    {
833
        $this->lazyLoad();
834
835
        if ($this->email == $email) {
836
            // The e-mail hasn't changed, don't do anything
837
            return;
838
        }
839
840
        $this->setVerified(false);
841
        $this->generateNewConfirmCode();
842
843
        $this->updateProperty($this->email, 'email', $email);
844
    }
845
846
    /**
847
     * Set whether the player has verified their e-mail address
848
     *
849
     * @param  bool $verified Whether the player is verified or not
850
     * @return self
851
     */
852
    public function setVerified($verified)
853
    {
854
        $this->lazyLoad();
855
856
        if ($verified) {
857
            $this->setConfirmCode(null);
858
        }
859
860
        return $this->updateProperty($this->verified, 'verified', $verified);
861
    }
862
863
    /**
864
     * Generate a new random confirmation token for e-mail address verification
865
     *
866
     * @return self
867
     */
868
    public function generateNewConfirmCode()
869
    {
870
        $generator = new SecureRandom();
0 ignored issues
show
Deprecated Code introduced by
The class Symfony\Component\Security\Core\Util\SecureRandom has been deprecated with message: since version 2.8, to be removed in 3.0. Use the random_bytes function instead

This class, trait or interface has been deprecated. The supplier of the file has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the type will be removed from the class and what other constant to use instead.

Loading history...
871
        $random = $generator->nextBytes(16);
872
873
        return $this->setConfirmCode(bin2hex($random));
874
    }
875
876
    /**
877
     * Set the confirmation token for e-mail address verification
878
     *
879
     * @param  string $code The confirmation code
880
     * @return self
881
     */
882 76
    private function setConfirmCode($code)
883
    {
884 76
        $this->lazyLoad();
885
886 76
        return $this->updateProperty($this->confirmCode, 'confirm_code', $code);
887
    }
888
889
    /**
890
     * Set what kind of events the player should be e-mailed about
891
     *
892
     * @param  string $receives The type of notification
893
     * @return self
894
     */
895
    public function setReceives($receives)
896
    {
897
        $this->lazyLoad();
898
899
        return $this->updateProperty($this->receives, 'receives', $receives);
900
    }
901
902
    /**
903
     * Set whether the callsign of the player is outdated
904
     *
905
     * @param  bool $outdated Whether the callsign is outdated
906
     * @return self
907
     */
908
    public function setOutdated($outdated)
909
    {
910
        $this->lazyLoad();
911 55
912
        return $this->updateProperty($this->outdated, 'outdated', $outdated);
913 55
    }
914 55
915
    /**
916
     * Set the player's description
917
     * @param string $description The description
918
     */
919
    public function setDescription($description)
920
    {
921 29
        $this->updateProperty($this->description, "description", $description);
922
    }
923 29
924 29
    /**
925
     * Set the player's timezone
926
     * @param string $timezone The timezone
927
     */
928
    public function setTimezone($timezone)
929
    {
930
        $this->updateProperty($this->timezone, "timezone", $timezone);
931
    }
932
933
    /**
934
     * Set the player's team
935
     * @param int $team The team's ID
936
     */
937
    public function setTeam($team)
938
    {
939
        $this->updateProperty($this->team, "team", $team);
940
    }
941
942
    /**
943
     * Set the match the player last participated in
944
     *
945
     * @param int $match The match's ID
946
     */
947
    public function setLastMatch($match)
948
    {
949
        $this->updateProperty($this->last_match, 'last_match', $match);
950
    }
951
952
    /**
953
     * Set the player's status
954
     * @param string $status The new status
955
     */
956
    public function setStatus($status)
957
    {
958
        $this->updateProperty($this->status, 'status', $status);
959
    }
960
961
    /**
962
     * Set the player's admin notes
963
     * @param  string $admin_notes The new admin notes
964
     * @return self
965
     */
966
    public function setAdminNotes($admin_notes)
967 1
    {
968
        return $this->updateProperty($this->admin_notes, 'admin_notes', $admin_notes);
969 1
    }
970
971
    /**
972
     * Set the player's country
973
     * @param  int   $country The ID of the new country
974
     * @return self
975
     */
976 1
    public function setCountry($country)
977
    {
978 1
        return $this->updateProperty($this->country, 'country', $country);
979
    }
980
981
    /**
982
     * Get the player's chosen theme preference
983
     *
984
     * @return string
985
     */
986
    public function getTheme()
987
    {
988
        $this->lazyLoad();
989
990
        return $this->theme;
991
    }
992
993
    /**
994
     * Set the site theme for the player
995
     *
996
     * If the chosen site theme is invalid, it'll be defaulted to the site default (the first theme defined)
997
     *
998
     * @param string $theme
999
     */
1000
    public function setTheme($theme)
1001
    {
1002
        $themes = array_column(Service::getSiteThemes(), 'slug');
1003
1004
        if (!in_array($theme, $themes)) {
1005
            $theme = Service::getDefaultSiteTheme();
1006
        }
1007
1008
        return $this->updateProperty($this->theme, 'theme', $theme);
1009
    }
1010
1011
    /**
1012
     * Whether or not this player has color blind assistance enabled.
1013
     *
1014
     * @return bool
1015
     */
1016
    public function hasColorBlindAssist()
1017
    {
1018
        $this->lazyLoad();
1019
1020
        return (bool)$this->color_blind_enabled;
1021
    }
1022
1023
    /**
1024
     * Set a player's setting for color blind assistance.
1025
     *
1026
     * @param bool $enabled
1027
     *
1028
     * @return self
1029
     */
1030
    public function setColorBlindAssist($enabled)
1031
    {
1032
        return $this->updateProperty($this->color_blind_enabled, 'color_blind_enabled', $enabled);
1033
    }
1034
1035
    /**
1036
     * Updates this player's last login
1037
     */
1038
    public function updateLastLogin()
1039
    {
1040
        $this->update("last_login", TimeDate::now()->toMysql());
1041
    }
1042
1043
    /**
1044 76
     * Get the player's username
1045
     * @return string The username
1046 76
     */
1047
    public function getUsername()
1048 76
    {
1049 76
        return $this->name;
1050 76
    }
1051
1052
    /**
1053
     * Get the player's username, safe for use in your HTML
1054
     * @return string The username
1055
     */
1056
    public function getEscapedUsername()
1057 76
    {
1058
        return $this->getEscapedName();
1059
    }
1060
1061
    /**
1062
     * Alias for Player::setUsername()
1063
     *
1064
     * @param  string $username The new username
1065
     * @return self
1066
     */
1067
    public function setName($username)
1068
    {
1069 23
        return $this->setUsername($username);
1070
    }
1071 23
1072
    /**
1073
     * Mark all the unread messages of a player as read
1074
     *
1075
     * @return void
1076
     */
1077
    public function markMessagesAsRead()
1078
    {
1079
        $this->db->execute(
1080 1
            "UPDATE `player_conversations` SET `read` = 1 WHERE `player` = ? AND `read` = 0",
1081
            array($this->id)
1082 1
        );
1083
    }
1084 1
1085
    /**
1086
     * Set the roles of a user
1087
     *
1088
     * @todo   Is it worth making this faster?
1089
     * @param  Role[] $roles The new roles of the user
1090
     * @return self
1091
     */
1092
    public function setRoles($roles)
1093
    {
1094
        $this->lazyLoad();
1095
1096
        $oldRoles = Role::mapToIds($this->roles);
1097
        $this->roles = $roles;
1098
        $roleIds = Role::mapToIds($roles);
1099
1100
        $newRoles     = array_diff($roleIds, $oldRoles);
1101
        $removedRoles = array_diff($oldRoles, $roleIds);
1102 1
1103
        foreach ($newRoles as $role) {
1104 1
            $this->modifyRole($role, 'add');
1105
        }
1106
1107
        foreach ($removedRoles as $role) {
1108
            $this->modifyRole($role, 'remove');
1109
        }
1110
1111
        $this->refresh();
1112
1113
        return $this;
1114
    }
1115
1116
    /**
1117
     * Give or remove a role to/form a player
1118
     *
1119
     * @param int    $role_id The role ID to add or remove
1120
     * @param string $action  Whether to "add" or "remove" a role for a player
1121
     *
1122
     * @return bool Whether the operation was successful or not
1123
     */
1124
    private function modifyRole($role_id, $action)
1125
    {
1126
        $role = Role::get($role_id);
1127
1128
        if ($role->isValid()) {
1129
            if ($action == "add") {
1130
                $this->db->execute("INSERT INTO player_roles (user_id, role_id) VALUES (?, ?)", array($this->getId(), $role_id));
1131
            } elseif ($action == "remove") {
1132
                $this->db->execute("DELETE FROM player_roles WHERE user_id = ? AND role_id = ?", array($this->getId(), $role_id));
1133
            } else {
1134
                throw new Exception("Unrecognized role action");
1135
            }
1136
1137
            return true;
1138
        }
1139
1140
        return false;
1141
    }
1142
1143
    /**
1144
     * Given a player's BZID, get a player object
1145
     *
1146
     * @param  int    $bzid The player's BZID
1147
     * @return Player
1148
     */
1149
    public static function getFromBZID($bzid)
1150
    {
1151
        return self::get(self::fetchIdFrom($bzid, "bzid"));
1152
    }
1153
1154
    /**
1155
     * Get a single player by their username
1156
     *
1157
     * @param  string $username The username to look for
1158
     * @return Player
1159
     */
1160
    public static function getFromUsername($username)
1161
    {
1162
        $player = static::get(self::fetchIdFrom($username, 'username'));
1163
1164
        return $player->inject('name', $username);
1165
    }
1166
1167
    /**
1168
     * Get all the players in the database that have an active status
1169
     * @return Player[] An array of player BZIDs
1170
     */
1171
    public static function getPlayers()
1172
    {
1173
        return self::arrayIdToModel(
1174
            self::fetchIdsFrom("status", array("active", "test"), false)
1175
        );
1176
    }
1177
1178
    /**
1179
     * Show the number of notifications the user hasn't read yet
1180
     * @return int
1181
     */
1182
    public function countUnreadNotifications()
1183
    {
1184
        return Notification::countUnreadNotifications($this->id);
1185
    }
1186
1187
    /**
1188
     * Count the number of matches a player has participated in
1189
     * @return int
1190
     */
1191
    public function getMatchCount()
1192
    {
1193
        if ($this->cachedMatchCount === null) {
1194
            $this->cachedMatchCount = Match::getQueryBuilder()
1195
                ->active()
1196
                ->with($this)
1197
                ->count();
1198
        }
1199
1200
        return $this->cachedMatchCount;
1201
    }
1202
1203
    /**
1204
     * Get the (victory/total matches) ratio of the player
1205
     * @return float
1206
     */
1207
    public function getMatchWinRatio()
1208
    {
1209
        $count = $this->getMatchCount();
1210
1211
        if ($count == 0) {
1212
            return 0;
1213
        }
1214
1215
        $wins = Match::getQueryBuilder()
1216
            ->active()
1217
            ->with($this, 'win')
1218
            ->count();
1219
1220
        return $wins / $count;
1221
    }
1222
1223
    /**
1224
     * Get the (total caps made by team/total matches) ratio of the player
1225
     * @return float
1226
     */
1227
    public function getMatchAverageCaps()
1228
    {
1229 1
        $count = $this->getMatchCount();
1230
1231 1
        if ($count == 0) {
1232 1
            return 0;
1233
        }
1234
1235
        // Get the sum of team A points if the player was in team A, team B points if the player was in team B
1236
        $query = $this->db->query("
1237
            SELECT
1238
              SUM(
1239
                IF(mp.team_loyalty = 0, team_a_points, team_b_points)
1240
              ) AS sum
1241 2
            FROM
1242
              matches
1243 2
            INNER JOIN
1244 2
              match_participation mp ON mp.match_id = matches.id
1245
            WHERE
1246
              status = 'entered' AND mp.user_id = ?
1247
        ", [$this->id]);
1248
1249
        return $query[0]['sum'] / $count;
1250
    }
1251 1
1252
    /**
1253 1
     * Get the match activity in matches per day for a player
1254
     *
1255
     * @return float
1256
     */
1257
    public function getMatchActivity()
1258
    {
1259 76
        if ($this->matchActivity !== null) {
1260
            return $this->matchActivity;
1261
        }
1262 76
1263
        $activity = 0.0;
1264
1265
        $matches = Match::getQueryBuilder()
1266
            ->active()
1267
            ->with($this)
1268
            ->where('time')->isAfter(TimeDate::from('45 days ago'))
1269
            ->getModels($fast = true);
1270
1271
        foreach ($matches as $match) {
1272 76
            $activity += $match->getActivity();
1273
        }
1274
1275
        return $activity;
1276
    }
1277
1278 76
    /**
1279
     * Return an array of matches this player participated in per month.
1280
     *
1281 76
     * ```
1282
     * ['yyyy-mm'] = <number of matches>
1283
     * ```
1284
     *
1285
     * @param TimeDate|string $timePeriod
1286
     *
1287
     * @return int[]
1288
     */
1289
    public function getMatchSummary($timePeriod = '1 year ago')
1290
    {
1291
        $since = ($timePeriod instanceof TimeDate) ? $timePeriod : TimeDate::from($timePeriod);
1292
1293
        if (!isset($this->cachedMatchSummary[(string)$timePeriod])) {
1294 76
            $this->cachedMatchSummary[(string)$timePeriod] = Match::getQueryBuilder()
1295
                ->active()
1296
                ->with($this)
1297
                ->where('time')->isAfter($since)
1298
                ->getSummary($since)
1299
            ;
1300
        }
1301
1302
        return $this->cachedMatchSummary[(string)$timePeriod];
1303
    }
1304
1305
    /**
1306
     * Show the number of messages the user hasn't read yet
1307
     * @return int
1308
     */
1309
    public function countUnreadMessages()
1310
    {
1311
        return $this->fetchCount("WHERE `player` = ? AND `read` = 0",
1312
            $this->id, 'player_conversations'
0 ignored issues
show
Documentation introduced by
$this->id is of type integer, but the function expects a array.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
1313
        );
1314
    }
1315
1316
    /**
1317
     * Get all of the members belonging to a team
1318
     * @param  int      $teamID The ID of the team to fetch the members of
1319
     * @return Player[] An array of Player objects of the team members
1320
     */
1321
    public static function getTeamMembers($teamID)
1322
    {
1323
        return self::arrayIdToModel(
1324
            self::fetchIds("WHERE team = ?", array($teamID))
1325
        );
1326
    }
1327
1328
    /**
1329 76
     * {@inheritdoc}
1330
     */
1331 76
    public static function getActiveStatuses()
1332 76
    {
1333 76
        return array('active', 'reported', 'test');
1334
    }
1335 76
1336 76
    /**
1337 76
     * {@inheritdoc}
1338 76
     */
1339 76
    public static function getEagerColumns($prefix = null)
1340 76
    {
1341 76
        $columns = [
1342 76
            'id',
1343 76
            'bzid',
1344 76
            'team',
1345 76
            'username',
1346 76
            'alias',
1347
            'status',
1348
            'avatar',
1349 76
            'country',
1350 76
        ];
1351 76
1352
        return self::formatColumns($prefix, $columns);
1353 76
    }
1354
1355
    /**
1356
     * {@inheritdoc}
1357
     */
1358
    public static function getLazyColumns($prefix = null)
1359
    {
1360
        $columns = [
1361
            'email',
1362
            'verified',
1363
            'receives',
1364
            'confirm_code',
1365
            'outdated',
1366
            'description',
1367
            'theme',
1368
            'color_blind_enabled',
1369
            'timezone',
1370
            'joined',
1371
            'last_login',
1372
            'last_match',
1373 76
            'admin_notes',
1374
        ];
1375
1376
        return self::formatColumns($prefix, $columns);
1377 76
    }
1378
1379
    /**
1380 76
     * Get a query builder for players
1381
     * @return PlayerQueryBuilder
1382 76
     */
1383
    public static function getQueryBuilder()
1384 76
    {
1385
        return new PlayerQueryBuilder('Player', array(
1386
            'columns' => array(
1387
                'name'     => 'username',
1388
                'team'     => 'team',
1389
                'outdated' => 'outdated',
1390
                'status'   => 'status',
1391
            ),
1392
            'name' => 'name',
1393
        ));
1394
    }
1395
1396
    /**
1397
     * Enter a new player to the database
1398
     * @param  int              $bzid        The player's bzid
1399
     * @param  string           $username    The player's username
1400
     * @param  int              $team        The player's team
1401
     * @param  string           $status      The player's status
1402
     * @param  int              $role_id     The player's role when they are first created
1403
     * @param  string           $avatar      The player's profile avatar
1404
     * @param  string           $description The player's profile description
1405
     * @param  int              $country     The player's country
1406
     * @param  string           $timezone    The player's timezone
1407
     * @param  string|\TimeDate $joined      The date the player joined
1408
     * @param  string|\TimeDate $last_login  The timestamp of the player's last login
1409
     * @return Player           An object representing the player that was just entered
1410
     */
1411 1
    public static function newPlayer($bzid, $username, $team = null, $status = "active", $role_id = self::PLAYER, $avatar = "", $description = "", $country = 1, $timezone = null, $joined = "now", $last_login = "now")
1412 1
    {
1413 1
        $joined = TimeDate::from($joined);
1414
        $last_login = TimeDate::from($last_login);
1415
        $timezone = ($timezone) ?: date_default_timezone_get();
1416
1417
        $player = self::create(array(
1418
            'bzid'        => $bzid,
1419
            'team'        => $team,
1420 76
            'username'    => $username,
1421
            'alias'       => self::generateAlias($username),
1422 76
            'status'      => $status,
1423
            'avatar'      => $avatar,
1424 76
            'description' => $description,
1425 76
            'country'     => $country,
1426
            'timezone'    => $timezone,
1427
            'joined'      => $joined->toMysql(),
1428
            'last_login'  => $last_login->toMysql(),
1429
        ));
1430
1431
        $player->addRole($role_id);
1432
        $player->getIdenticon($player->getId());
1433
        $player->setUsername($username);
1434 1
1435
        return $player;
1436 1
    }
1437
1438
    /**
1439
     * Determine if a player exists in the database
1440
     * @param  int  $bzid The player's bzid
1441
     * @return bool Whether the player exists in the database
1442
     */
1443
    public static function playerBZIDExists($bzid)
1444
    {
1445
        return self::getFromBZID($bzid)->isValid();
1446
    }
1447 1
1448
    /**
1449 1
     * Change a player's callsign and add it to the database if it does not
1450
     * exist as a past callsign
1451
     *
1452 1
     * @param  string $username The new username of the player
1453
     * @return self
1454
     */
1455
    public function setUsername($username)
1456
    {
1457
        // The player's username was just fetched from BzDB, it's definitely not
1458
        // outdated
1459
        $this->setOutdated(false);
1460
1461
        // Players who have this player's username are considered outdated
1462 1
        $this->db->execute("UPDATE {$this->table} SET outdated = 1 WHERE username = ? AND id != ?", array($username, $this->id));
1463
1464 1
        if ($username === $this->name) {
1465
            // The player's username hasn't changed, no need to do anything
1466
            return $this;
1467
        }
1468
1469
        // Players who used to have our player's username are not outdated anymore,
1470
        // unless they are more than one.
1471
        // Even though we are sure that the old and new usernames are not equal,
1472
        // MySQL makes a different type of string equality tests, which is why we
1473 1
        // also check IDs to make sure not to affect our own player's outdatedness.
1474
        $this->db->execute("
1475 1
            UPDATE {$this->table} SET outdated =
1476
                (SELECT (COUNT(*)>1) FROM (SELECT 1 FROM {$this->table} WHERE username = ? AND id != ?) t)
1477
            WHERE username = ? AND id != ?",
1478
            array($this->name, $this->id, $this->name, $this->id));
1479
1480
        $this->updateProperty($this->name, 'username', $username);
1481
        $this->db->execute("INSERT IGNORE INTO past_callsigns (player, username) VALUES (?, ?)", array($this->id, $username));
1482
        $this->resetAlias();
1483
1484
        return $this;
1485
    }
1486
1487
    /**
1488
     * Alphabetical order function for use in usort (case-insensitive)
1489
     * @return Closure The sort function
1490
     */
1491
    public static function getAlphabeticalSort()
1492
    {
1493
        return function (Player $a, Player $b) {
1494
            return strcasecmp($a->getUsername(), $b->getUsername());
1495
        };
1496
    }
1497
1498
    /**
1499
     * {@inheritdoc}
1500
     * @todo Add a constraint that does this automatically
1501
     */
1502
    public function wipe()
1503
    {
1504
        $this->db->execute("DELETE FROM past_callsigns WHERE player = ?", $this->id);
1505
1506
        parent::wipe();
1507
    }
1508
1509
    /**
1510
     * Find whether the player can delete a model
1511
     *
1512
     * @param  PermissionModel $model       The model that will be seen
1513
     * @param  bool         $showDeleted Whether to show deleted models to admins
1514
     * @return bool
1515
     */
1516
    public function canSee($model, $showDeleted = false)
1517
    {
1518
        return $model->canBeSeenBy($this, $showDeleted);
1519
    }
1520
1521
    /**
1522
     * Find whether the player can delete a model
1523
     *
1524
     * @param  PermissionModel $model The model that will be deleted
1525
     * @param  bool         $hard  Whether to check for hard-delete perms, as opposed
1526
     *                                to soft-delete ones
1527
     * @return bool
1528
     */
1529
    public function canDelete($model, $hard = false)
1530
    {
1531
        if ($hard) {
1532
            return $model->canBeHardDeletedBy($this);
1533
        } else {
1534
            return $model->canBeSoftDeletedBy($this);
1535
        }
1536
    }
1537
1538
    /**
1539
     * Find whether the player can create a model
1540
     *
1541
     * @param  string  $modelName The PHP class identifier of the model type
1542
     * @return bool
1543
     */
1544
    public function canCreate($modelName)
1545
    {
1546
        return $modelName::canBeCreatedBy($this);
1547
    }
1548
1549
    /**
1550
     * Find whether the player can edit a model
1551
     *
1552
     * @param  PermissionModel $model The model which will be edited
1553
     * @return bool
1554
     */
1555
    public function canEdit($model)
1556
    {
1557
        return $model->canBeEditedBy($this);
1558
    }
1559
}
1560