Completed
Push — feature/player-elo ( 21fda9...de6928 )
by Vladimir
07:43
created

Player::setOutdated()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

Changes 0
Metric Value
dl 0
loc 6
ccs 3
cts 3
cp 1
rs 9.4285
c 0
b 0
f 0
cc 1
eloc 3
nc 1
nop 1
crap 1
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 status
47
     * @var string
48
     */
49
    protected $status;
50
51
    /**
52
     * The player's e-mail address
53
     * @var string
54
     */
55
    protected $email;
56
57
    /**
58
     * Whether the player has verified their e-mail address
59
     * @var bool
60
     */
61
    protected $verified;
62
63
    /**
64
     * What kind of events the player should be e-mailed about
65
     * @var string
66
     */
67
    protected $receives;
68
69
    /**
70
     * A confirmation code for the player's e-mail address verification
71
     * @var string
72
     */
73
    protected $confirmCode;
74
75
    /**
76
     * Whether the callsign of the player is outdated
77
     * @var bool
78
     */
79
    protected $outdated;
80
81
    /**
82
     * The player's profile description
83
     * @var string
84
     */
85
    protected $description;
86
87
    /**
88
     * The id of the player's country
89
     * @var int
90
     */
91
    protected $country;
92
93
    /**
94
     * The player's timezone PHP identifier, e.g. "Europe/Paris"
95
     * @var string
96
     */
97
    protected $timezone;
98
99
    /**
100
     * The date the player joined the site
101
     * @var TimeDate
102
     */
103
    protected $joined;
104
105
    /**
106
     * The date of the player's last login
107
     * @var TimeDate
108
     */
109
    protected $last_login;
110
111
    /**
112
     * The date of the player's last match
113
     * @var Match
114
     */
115
    protected $last_match;
116
117
    /**
118
     * The roles a player belongs to
119
     * @var Role[]
120
     */
121
    protected $roles;
122
123
    /**
124
     * The permissions a player has
125
     * @var Permission[]
126
     */
127
    protected $permissions;
128
129
    /**
130
     * A section for admins to write notes about players
131
     * @var string
132
     */
133
    protected $admin_notes;
134
135
    /**
136
     * The ban of the player, or null if the player is not banned
137
     * @var Ban|null
138
     */
139
    protected $ban;
140
141
    /**
142
     * Cached results for match summaries
143
     *
144
     * @var array
145
     */
146
    private $cachedMatchSummary;
147
148
    /**
149
     * The cached match count for a player
150
     *
151
     * @var int
152
     */
153
    private $cachedMatchCount = null;
154
155
    private $eloSeason;
156
    private $eloSeasonHistory;
157
158
    private $matchActivity;
159
160
    /**
161
     * The name of the database table used for queries
162
     */
163
    const TABLE = "players";
164
165
    /**
166
     * The location where avatars will be stored
167
     */
168
    const AVATAR_LOCATION = "/web/assets/imgs/avatars/players/";
169
170
    const EDIT_PERMISSION = Permission::EDIT_USER;
171
    const SOFT_DELETE_PERMISSION = Permission::SOFT_DELETE_USER;
172
    const HARD_DELETE_PERMISSION = Permission::HARD_DELETE_USER;
173
174
    /**
175
     * {@inheritdoc}
176
     */
177 76
    protected function assignResult($player)
178
    {
179 76
        $this->bzid = $player['bzid'];
180 76
        $this->name = $player['username'];
181 76
        $this->alias = $player['alias'];
182 76
        $this->team = $player['team'];
183 76
        $this->status = $player['status'];
184 76
        $this->avatar = $player['avatar'];
185 76
        $this->country = $player['country'];
186
187 76
        if (key_exists('activity', $player)) {
188
            $this->matchActivity = ($player['activity'] != null) ? $player['activity'] : 0.0;
189
        }
190 76
    }
191
192
    /**
193
     * {@inheritdoc}
194
     */
195 76
    protected function assignLazyResult($player)
196
    {
197 76
        $this->email = $player['email'];
198 76
        $this->verified = $player['verified'];
199 76
        $this->receives = $player['receives'];
200 76
        $this->confirmCode = $player['confirm_code'];
201 76
        $this->outdated = $player['outdated'];
202 76
        $this->description = $player['description'];
203 76
        $this->timezone = $player['timezone'];
204 76
        $this->joined = TimeDate::fromMysql($player['joined']);
205 76
        $this->last_login = TimeDate::fromMysql($player['last_login']);
206 76
        $this->last_match = Match::get($player['last_match']);
207 76
        $this->admin_notes = $player['admin_notes'];
208 76
        $this->ban = Ban::getBan($this->id);
209
210 76
        $this->cachedMatchSummary = [];
211
212 76
        $this->updateUserPermissions();
213 76
    }
214
215
    /**
216
     * Add a player a new role
217
     *
218
     * @param Role|int $role_id The role ID to add a player to
219
     *
220
     * @return bool Whether the operation was successful or not
221
     */
222 76
    public function addRole($role_id)
223
    {
224 76
        if ($role_id instanceof Role) {
225 1
            $role_id = $role_id->getId();
226
        }
227
228 76
        $this->lazyLoad();
229
230
        // Make sure the player doesn't already have the role
231 76
        foreach ($this->roles as $playerRole) {
232 14
            if ($playerRole->getId() == $role_id) {
233 14
                return false;
234
            }
235
        }
236
237 76
        $status = $this->modifyRole($role_id, "add");
238 76
        $this->refresh();
239
240 76
        return $status;
241
    }
242
243
    /**
244
     * Get the notes admins have left about a player
245
     * @return string The notes
246
     */
247
    public function getAdminNotes()
248
    {
249
        $this->lazyLoad();
250
251
        return $this->admin_notes;
252
    }
253
254
    /**
255
     * Get the player's BZID
256
     * @return int The BZID
257
     */
258 22
    public function getBZID()
259
    {
260 22
        return $this->bzid;
261
    }
262
263
    /**
264
     * Get the country a player belongs to
265
     *
266
     * @return Country The country belongs to
267
     */
268 1
    public function getCountry()
269
    {
270 1
        return Country::get($this->country);
271
    }
272
273
    /**
274
     * Get the e-mail address of the player
275
     *
276
     * @return string The address
277
     */
278
    public function getEmailAddress()
279
    {
280
        $this->lazyLoad();
281
282
        return $this->email;
283
    }
284
285
    /**
286
     * Build a key that we'll use for caching season Elo data in this model
287
     *
288
     * @param  string|null $season The season to get
289
     * @param  int|null    $year   The year of the season to get
290
     *
291
     * @return string
292
     */
293 30
    private function buildSeasonKey(&$season, &$year)
294
    {
295 30
        if ($season === null) {
296 29
            $season = Season::getCurrentSeason();
297
        }
298
299 30
        if ($year === null) {
300 29
            $year = Carbon::now()->year;
301
        }
302
303 30
        return sprintf('%s-%s', $year, $season);
304
    }
305
306
    /**
307
     * Build a key to use for caching season Elo data in this model from a timestamp
308
     *
309
     * @param DateTime $timestamp
310
     *
311
     * @return string
312
     */
313 3
    private function buildSeasonKeyFromTimestamp(\DateTime $timestamp)
314
    {
315 3
        $seasonInfo = Season::getSeason($timestamp);
316
317 3
        return sprintf('%s-%s', $seasonInfo['year'], $seasonInfo['season']);
318
    }
319
320
    /**
321
     * Remove all Elo data for this model for matches occurring after the given match (inclusive)
322
     *
323
     * This function will not remove the Elo data for this match from the database. Ideally, this function should only
324
     * be called during Elo recalculation for this match.
325
     *
326
     * @internal
327
     *
328
     * @param Match $match
329
     *
330
     * @see Match::recalculateElo()
331
     */
332 3
    public function invalidateMatchFromCache(Match $match)
333
    {
334 3
        $seasonKey = $this->buildSeasonKeyFromTimestamp($match->getTimestamp());
335 3
        $seasonElo = null;
336
337
        // If we have an existing season history cached, save a reference to it for easy access. Don't create one if
338
        // nothing is cached or else it'll cause for an empty cache to be created
339 3
        if (isset($this->eloSeasonHistory[$seasonKey])) {
340 2
            $seasonElo = &$this->eloSeasonHistory[$seasonKey];
341
        }
342
343
        // Unset the currently cached Elo for a player so next time Player::getElo() is called, it'll pull the latest
344
        // available Elo
345 3
        unset($this->eloSeason[$seasonKey]);
346
347 3
        if ($seasonElo === null) {
348 1
            return;
349
        }
350
351
        // This function is called when we recalculate, so assume that the match will be recent, therefore towards the
352
        // end of the Elo history array. We splice the array to have all Elo data after this match to be removed.
353 2
        foreach (array_reverse($seasonElo) as $key => $match) {
354 2
            if ($match['match'] === $match) {
355
                $seasonElo = array_splice($seasonElo, $key);
356 2
                break;
357
            }
358
        }
359 2
    }
360
361
    /**
362
     * Get the Elo changes for a player for a given season
363
     *
364
     * @param  string|null $season The season to get
365
     * @param  int|null    $year   The year of the season to get
366
     *
367
     * @return array
368
     */
369 30
    public function getEloSeasonHistory($season = null, $year = null)
370
    {
371 30
        $seasonKey = $this->buildSeasonKey($season, $year);
372
373
        // This season's already been cached
374 30
        if (isset($this->eloSeasonHistory[$seasonKey])) {
375 29
            return $this->eloSeasonHistory[$seasonKey];
376
        }
377
378 30
        $this->eloSeasonHistory[$seasonKey] = $this->db->query('
379
          SELECT
380
            elo_new AS elo,
381
            match_id AS `match`,
382
            MONTH(matches.timestamp) AS `month`,
383
            YEAR(matches.timestamp) AS `year`,
384
            DAY(matches.timestamp) AS `day`
385
          FROM
386
            player_elo
387
            LEFT JOIN matches ON player_elo.match_id = matches.id
388
          WHERE
389
            user_id = ? AND season_period = ? AND season_year = ?
390
          ORDER BY
391
            match_id ASC
392 30
        ', [ $this->getId(), $season, $year ]);
393
394 30
        array_unshift($this->eloSeasonHistory[$seasonKey], [
395 30
            'elo' => 1200,
396
            'match' => null,
397 30
            'month' => Season::getCurrentSeasonRange($season)->getStartOfRange()->month,
398 30
            'year' => $year,
399 30
            'day' => 1
400
        ]);
401
402 30
        return $this->eloSeasonHistory[$seasonKey];
403
    }
404
405
    /**
406
     * Get the player's Elo for a season.
407
     *
408
     * With the default arguments, it will fetch the Elo for the current season.
409
     *
410
     * @param string|null $season The season we're looking for: winter, spring, summer, or fall
411
     * @param int|null    $year   The year of the season we're looking for
412
     *
413
     * @return int The player's Elo
414
     */
415 30
    public function getElo($season = null, $year = null)
416
    {
417 30
        $this->getEloSeasonHistory($season, $year);
418 30
        $seasonKey = $this->buildSeasonKey($season, $year);
419
420 30
        if (isset($this->eloSeason[$seasonKey])) {
421 29
            return $this->eloSeason[$seasonKey];
422
        }
423
424 30
        $season = &$this->eloSeasonHistory[$seasonKey];
425
426 30
        if (!empty($season)) {
427 30
            $elo = end($season);
428 30
            $this->eloSeason[$seasonKey] = ($elo !== false) ? $elo['elo'] : 1200;
429
        } else {
430
            $this->eloSeason[$seasonKey] = 1200;
431
        }
432
433 30
        return $this->eloSeason[$seasonKey];
434
    }
435
436
    /**
437
     * Adjust the Elo of a player for the current season based on a Match
438
     *
439
     * **Warning:** If $match is null, the Elo for the player will be modified but the value will not be persisted to
440
     * the database.
441
     *
442
     * @param int        $adjust The value to be added to the current ELO (negative to subtract)
443
     * @param Match|null $match  The match where this Elo change took place
444
     */
445 30
    public function adjustElo($adjust, Match $match = null)
446
    {
447 30
        $timestamp = ($match !== null) ? $match->getTimestamp() : (Carbon::now());
448 30
        $seasonInfo = Season::getSeason($timestamp);
449
450
        // Get the current Elo for the player, even if it's the default 1200. We need the value for adjusting
451 30
        $elo = $this->getElo($seasonInfo['season'], $seasonInfo['year']);
452 30
        $seasonKey = sprintf('%s-%s', $seasonInfo['year'], $seasonInfo['season']);
453
454 30
        $this->eloSeason[$seasonKey] += $adjust;
455
456 30
        if ($match !== null && $this->isValid()) {
457 29
            $this->db->execute('
458
              INSERT INTO player_elo VALUES (?, ?, ?, ?, ?, ?)
459 29
            ', [ $this->getId(), $match->getId(), $seasonInfo['season'], $seasonInfo['year'], $elo, $this->eloSeason[$seasonKey] ]);
460
        }
461 30
    }
462
463
    /**
464
     * Returns whether the player has verified their e-mail address
465
     *
466
     * @return bool `true` for verified players
467
     */
468
    public function isVerified()
469
    {
470
        $this->lazyLoad();
471
472
        return $this->verified;
473
    }
474
475
    /**
476
     * Returns the confirmation code for the player's e-mail address verification
477
     *
478
     * @return string The player's confirmation code
479
     */
480
    public function getConfirmCode()
481
    {
482
        $this->lazyLoad();
483
484
        return $this->confirmCode;
485
    }
486
487
    /**
488
     * Returns what kind of events the player should be e-mailed about
489
     *
490
     * @return string The type of notifications
491
     */
492
    public function getReceives()
493
    {
494
        $this->lazyLoad();
495
496
        return $this->receives;
497
    }
498
499
    /**
500
     * Finds out whether the specified player wants and can receive an e-mail
501
     * message
502
     *
503
     * @param  string  $type
504
     * @return bool `true` if the player should be sent an e-mail
505
     */
506 1
    public function canReceive($type)
507
    {
508 1
        $this->lazyLoad();
509
510 1
        if (!$this->email || !$this->isVerified()) {
511
            // Unverified e-mail means the user will receive nothing
512 1
            return false;
513
        }
514
515
        if ($this->receives == 'everything') {
516
            return true;
517
        }
518
519
        return $this->receives == $type;
520
    }
521
522
    /**
523
     * Find out whether the specified confirmation code is correct
524
     *
525
     * This method protects against timing attacks
526
     *
527
     * @param  string $code The confirmation code to check
528
     * @return bool `true` for a correct e-mail verification code
529
     */
530
    public function isCorrectConfirmCode($code)
531
    {
532
        $this->lazyLoad();
533
534
        if ($this->confirmCode === null) {
535
            return false;
536
        }
537
538
        return StringUtils::equals($code, $this->confirmCode);
539
    }
540
541
    /**
542
     * Get the player's sanitized description
543
     * @return string The description
544
     */
545
    public function getDescription()
546
    {
547
        $this->lazyLoad();
548
549
        return $this->description;
550
    }
551
552
    /**
553
     * Get the joined date of the player
554
     * @return TimeDate The joined date of the player
555
     */
556
    public function getJoinedDate()
557
    {
558
        $this->lazyLoad();
559
560
        return $this->joined->copy();
561
    }
562
563
    /**
564
     * Get all of the known IPs used by the player
565
     *
566
     * @return string[][] An array containing IPs and hosts
567
     */
568
    public function getKnownIPs()
569
    {
570
        return $this->db->query(
571
            'SELECT DISTINCT ip, host FROM visits WHERE player = ? GROUP BY ip, host ORDER BY MAX(timestamp) DESC LIMIT 10',
572
            array($this->getId())
573
        );
574
    }
575
576
    /**
577
     * Get the last login for a player
578
     * @return TimeDate The date of the last login
579
     */
580
    public function getLastLogin()
581
    {
582
        $this->lazyLoad();
583
584
        return $this->last_login->copy();
585
    }
586
587
    /**
588
     * Get the last match
589
     * @return Match
590
     */
591
    public function getLastMatch()
592
    {
593
        $this->lazyLoad();
594
595
        return $this->last_match;
596
    }
597
598
    /**
599
     * Get all of the callsigns a player has used to log in to the website
600
     * @return string[] An array containing all of the past callsigns recorded for a player
601
     */
602
    public function getPastCallsigns()
603
    {
604
        return self::fetchIds("WHERE player = ?", array($this->id), "past_callsigns", "username");
605
    }
606
607
    /**
608
     * Get the player's team
609
     * @return Team The object representing the team
610
     */
611 23
    public function getTeam()
612
    {
613 23
        return Team::get($this->team);
614
    }
615
616
    /**
617
     * Get the player's timezone PHP identifier (example: "Europe/Paris")
618
     * @return string The timezone
619
     */
620 1
    public function getTimezone()
621
    {
622 1
        $this->lazyLoad();
623
624 1
        return ($this->timezone) ?: date_default_timezone_get();
625
    }
626
627
    /**
628
     * Get the roles of the player
629
     * @return Role[]
630
     */
631
    public function getRoles()
632
    {
633
        $this->lazyLoad();
634
635
        return $this->roles;
636
    }
637
638
    /**
639
     * Rebuild the list of permissions a user has been granted
640
     */
641 76
    private function updateUserPermissions()
642
    {
643 76
        $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...
644 76
        $this->permissions = array();
645
646 76
        foreach ($this->roles as $role) {
647 76
            $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...
648
        }
649 76
    }
650
651
    /**
652
     * Check if a player has a specific permission
653
     *
654
     * @param string|null $permission The permission to check for
655
     *
656
     * @return bool Whether or not the player has the permission
657
     */
658 2
    public function hasPermission($permission)
659
    {
660 2
        if ($permission === null) {
661 1
            return false;
662
        }
663
664 2
        $this->lazyLoad();
665
666 2
        return isset($this->permissions[$permission]);
667
    }
668
669
    /**
670
     * Check whether or not a player been in a match or has logged on in the specified amount of time to be considered
671
     * active
672
     *
673
     * @return bool True if the player has been active
674
     */
675
    public function hasBeenActive()
676
    {
677
        $this->lazyLoad();
678
679
        $interval  = Service::getParameter('bzion.miscellaneous.active_interval');
680
        $lastLogin = $this->last_login->copy()->modify($interval);
681
682
        $hasBeenActive = (TimeDate::now() <= $lastLogin);
683
684
        if ($this->last_match->isValid()) {
685
            $lastMatch = $this->last_match->getTimestamp()->copy()->modify($interval);
686
            $hasBeenActive = ($hasBeenActive || TimeDate::now() <= $lastMatch);
687
        }
688
689
        return $hasBeenActive;
690
    }
691
692
    /**
693
     * Check whether the callsign of the player is outdated
694
     *
695
     * Returns true if this player has probably changed their callsign, making
696
     * the current username stored in the database obsolete
697
     *
698
     * @return bool Whether or not the player is disabled
699
     */
700
    public function isOutdated()
701
    {
702
        $this->lazyLoad();
703
704
        return $this->outdated;
705
    }
706
707
    /**
708
     * Check if a player's account has been disabled
709
     *
710
     * @return bool Whether or not the player is disabled
711
     */
712
    public function isDisabled()
713
    {
714
        return $this->status == "disabled";
715
    }
716
717
    /**
718
     * Check if everyone can log in as this user on a test environment
719
     *
720
     * @return bool
721
     */
722 1
    public function isTestUser()
723
    {
724 1
        return $this->status == "test";
725
    }
726
727
    /**
728
     * Check if a player is teamless
729
     *
730
     * @return bool True if the player is teamless
731
     */
732 55
    public function isTeamless()
733
    {
734 55
        return empty($this->team);
735
    }
736
737
    /**
738
     * Mark a player's account as banned
739
     */
740 1
    public function markAsBanned()
741
    {
742 1
        if ($this->status != 'active') {
743
            return $this;
744
        }
745
746 1
        return $this->updateProperty($this->status, "status", "banned");
747
    }
748
749
    /**
750
     * Mark a player's account as unbanned
751
     */
752
    public function markAsUnbanned()
753
    {
754
        if ($this->status != 'banned') {
755
            return $this;
756
        }
757
758
        return $this->updateProperty($this->status, "status", "active");
759
    }
760
761
    /**
762
     * Find out if a player is banned
763
     *
764
     * @return bool
765
     */
766 2
    public function isBanned()
767
    {
768 2
        return Ban::getBan($this->id) !== null;
769
    }
770
771
    /**
772
     * Get the ban of the player
773
     *
774
     * This method performs a load of all the lazy parameters of the Player
775
     *
776
     * @return Ban|null The current ban of the player, or null if the player is
777
     *                  is not banned
778
     */
779
    public function getBan()
780
    {
781
        $this->lazyLoad();
782
783
        return $this->ban;
784
    }
785
786
    /**
787
     * Remove a player from a role
788
     *
789
     * @param int $role_id The role ID to add or remove
790
     *
791
     * @return bool Whether the operation was successful or not
792
     */
793
    public function removeRole($role_id)
794
    {
795
        $status = $this->modifyRole($role_id, "remove");
796
        $this->refresh();
797
798
        return $status;
799
    }
800
801
    /**
802
     * Set the player's email address and reset their verification status
803
     * @param string $email The address
804
     */
805
    public function setEmailAddress($email)
806
    {
807
        $this->lazyLoad();
808
809
        if ($this->email == $email) {
810
            // The e-mail hasn't changed, don't do anything
811
            return;
812
        }
813
814
        $this->setVerified(false);
815
        $this->generateNewConfirmCode();
816
817
        $this->updateProperty($this->email, 'email', $email);
818
    }
819
820
    /**
821
     * Set whether the player has verified their e-mail address
822
     *
823
     * @param  bool $verified Whether the player is verified or not
824
     * @return self
825
     */
826
    public function setVerified($verified)
827
    {
828
        $this->lazyLoad();
829
830
        if ($verified) {
831
            $this->setConfirmCode(null);
832
        }
833
834
        return $this->updateProperty($this->verified, 'verified', $verified);
835
    }
836
837
    /**
838
     * Generate a new random confirmation token for e-mail address verification
839
     *
840
     * @return self
841
     */
842
    public function generateNewConfirmCode()
843
    {
844
        $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...
845
        $random = $generator->nextBytes(16);
846
847
        return $this->setConfirmCode(bin2hex($random));
848
    }
849
850
    /**
851
     * Set the confirmation token for e-mail address verification
852
     *
853
     * @param  string $code The confirmation code
854
     * @return self
855
     */
856
    private function setConfirmCode($code)
857
    {
858
        $this->lazyLoad();
859
860
        return $this->updateProperty($this->confirmCode, 'confirm_code', $code);
861
    }
862
863
    /**
864
     * Set what kind of events the player should be e-mailed about
865
     *
866
     * @param  string $receives The type of notification
867
     * @return self
868
     */
869
    public function setReceives($receives)
870
    {
871
        $this->lazyLoad();
872
873
        return $this->updateProperty($this->receives, 'receives', $receives);
874
    }
875
876
    /**
877
     * Set whether the callsign of the player is outdated
878
     *
879
     * @param  bool $outdated Whether the callsign is outdated
880
     * @return self
881
     */
882 76
    public function setOutdated($outdated)
883
    {
884 76
        $this->lazyLoad();
885
886 76
        return $this->updateProperty($this->outdated, 'outdated', $outdated);
887
    }
888
889
    /**
890
     * Set the player's description
891
     * @param string $description The description
892
     */
893
    public function setDescription($description)
894
    {
895
        $this->updateProperty($this->description, "description", $description);
896
    }
897
898
    /**
899
     * Set the player's timezone
900
     * @param string $timezone The timezone
901
     */
902
    public function setTimezone($timezone)
903
    {
904
        $this->updateProperty($this->timezone, "timezone", $timezone);
905
    }
906
907
    /**
908
     * Set the player's team
909
     * @param int $team The team's ID
910
     */
911 55
    public function setTeam($team)
912
    {
913 55
        $this->updateProperty($this->team, "team", $team);
914 55
    }
915
916
    /**
917
     * Set the match the player last participated in
918
     *
919
     * @param int $match The match's ID
920
     */
921 29
    public function setLastMatch($match)
922
    {
923 29
        $this->updateProperty($this->last_match, 'last_match', $match);
924 29
    }
925
926
    /**
927
     * Set the player's status
928
     * @param string $status The new status
929
     */
930
    public function setStatus($status)
931
    {
932
        $this->updateProperty($this->status, 'status', $status);
933
    }
934
935
    /**
936
     * Set the player's admin notes
937
     * @param  string $admin_notes The new admin notes
938
     * @return self
939
     */
940
    public function setAdminNotes($admin_notes)
941
    {
942
        return $this->updateProperty($this->admin_notes, 'admin_notes', $admin_notes);
943
    }
944
945
    /**
946
     * Set the player's country
947
     * @param  int   $country The ID of the new country
948
     * @return self
949
     */
950
    public function setCountry($country)
951
    {
952
        return $this->updateProperty($this->country, 'country', $country);
953
    }
954
955
    /**
956
     * Updates this player's last login
957
     */
958
    public function updateLastLogin()
959
    {
960
        $this->update("last_login", TimeDate::now()->toMysql());
961
    }
962
963
    /**
964
     * Get the player's username
965
     * @return string The username
966
     */
967 1
    public function getUsername()
968
    {
969 1
        return $this->name;
970
    }
971
972
    /**
973
     * Get the player's username, safe for use in your HTML
974
     * @return string The username
975
     */
976 1
    public function getEscapedUsername()
977
    {
978 1
        return $this->getEscapedName();
979
    }
980
981
    /**
982
     * Alias for Player::setUsername()
983
     *
984
     * @param  string $username The new username
985
     * @return self
986
     */
987
    public function setName($username)
988
    {
989
        return $this->setUsername($username);
990
    }
991
992
    /**
993
     * Mark all the unread messages of a player as read
994
     *
995
     * @return void
996
     */
997
    public function markMessagesAsRead()
998
    {
999
        $this->db->execute(
1000
            "UPDATE `player_conversations` SET `read` = 1 WHERE `player` = ? AND `read` = 0",
1001
            array($this->id)
1002
        );
1003
    }
1004
1005
    /**
1006
     * Set the roles of a user
1007
     *
1008
     * @todo   Is it worth making this faster?
1009
     * @param  Role[] $roles The new roles of the user
1010
     * @return self
1011
     */
1012
    public function setRoles($roles)
1013
    {
1014
        $this->lazyLoad();
1015
1016
        $oldRoles = Role::mapToIds($this->roles);
1017
        $this->roles = $roles;
1018
        $roleIds = Role::mapToIds($roles);
1019
1020
        $newRoles     = array_diff($roleIds, $oldRoles);
1021
        $removedRoles = array_diff($oldRoles, $roleIds);
1022
1023
        foreach ($newRoles as $role) {
1024
            $this->modifyRole($role, 'add');
1025
        }
1026
1027
        foreach ($removedRoles as $role) {
1028
            $this->modifyRole($role, 'remove');
1029
        }
1030
1031
        $this->refresh();
1032
1033
        return $this;
1034
    }
1035
1036
    /**
1037
     * Give or remove a role to/form a player
1038
     *
1039
     * @param int    $role_id The role ID to add or remove
1040
     * @param string $action  Whether to "add" or "remove" a role for a player
1041
     *
1042
     * @return bool Whether the operation was successful or not
1043
     */
1044 76
    private function modifyRole($role_id, $action)
1045
    {
1046 76
        $role = Role::get($role_id);
1047
1048 76
        if ($role->isValid()) {
1049 76
            if ($action == "add") {
1050 76
                $this->db->execute("INSERT INTO player_roles (user_id, role_id) VALUES (?, ?)", array($this->getId(), $role_id));
1051
            } elseif ($action == "remove") {
1052
                $this->db->execute("DELETE FROM player_roles WHERE user_id = ? AND role_id = ?", array($this->getId(), $role_id));
1053
            } else {
1054
                throw new Exception("Unrecognized role action");
1055
            }
1056
1057 76
            return true;
1058
        }
1059
1060
        return false;
1061
    }
1062
1063
    /**
1064
     * Given a player's BZID, get a player object
1065
     *
1066
     * @param  int    $bzid The player's BZID
1067
     * @return Player
1068
     */
1069 23
    public static function getFromBZID($bzid)
1070
    {
1071 23
        return self::get(self::fetchIdFrom($bzid, "bzid"));
1072
    }
1073
1074
    /**
1075
     * Get a single player by their username
1076
     *
1077
     * @param  string $username The username to look for
1078
     * @return Player
1079
     */
1080 1
    public static function getFromUsername($username)
1081
    {
1082 1
        $player = static::get(self::fetchIdFrom($username, 'username'));
1083
1084 1
        return $player->inject('name', $username);
1085
    }
1086
1087
    /**
1088
     * Get all the players in the database that have an active status
1089
     * @return Player[] An array of player BZIDs
1090
     */
1091
    public static function getPlayers()
1092
    {
1093
        return self::arrayIdToModel(
1094
            self::fetchIdsFrom("status", array("active", "test"), false)
1095
        );
1096
    }
1097
1098
    /**
1099
     * Show the number of notifications the user hasn't read yet
1100
     * @return int
1101
     */
1102 1
    public function countUnreadNotifications()
1103
    {
1104 1
        return Notification::countUnreadNotifications($this->id);
1105
    }
1106
1107
    /**
1108
     * Count the number of matches a player has participated in
1109
     * @return int
1110
     */
1111
    public function getMatchCount()
1112
    {
1113
        if ($this->cachedMatchCount === null) {
1114
            $this->cachedMatchCount = Match::getQueryBuilder()
1115
                ->active()
1116
                ->with($this)
1117
                ->count();
1118
        }
1119
1120
        return $this->cachedMatchCount;
1121
    }
1122
1123
    /**
1124
     * Get the (victory/total matches) ratio of the player
1125
     * @return float
1126
     */
1127
    public function getMatchWinRatio()
1128
    {
1129
        $count = $this->getMatchCount();
1130
1131
        if ($count == 0) {
1132
            return 0;
1133
        }
1134
1135
        $wins = Match::getQueryBuilder()
1136
            ->active()
1137
            ->with($this, 'win')
1138
            ->count();
1139
1140
        return $wins / $count;
1141
    }
1142
1143
    /**
1144
     * Get the (total caps made by team/total matches) ratio of the player
1145
     * @return float
1146
     */
1147
    public function getMatchAverageCaps()
1148
    {
1149
        $count = $this->getMatchCount();
1150
1151
        if ($count == 0) {
1152
            return 0;
1153
        }
1154
1155
        // Get the sum of team A points if the player was in team A, team B
1156
        // points if the player was in team B, and their average if the player
1157
        // was on both teams for some reason
1158
        $query = $this->db->query(
1159
            "SELECT SUM(
1160
                IF(
1161
                    FIND_IN_SET(?, team_a_players) AND FIND_IN_SET(?, team_b_players),
1162
                    (team_a_points+team_b_points)/2,
1163
                    IF(FIND_IN_SET(?, team_a_players), team_a_points, team_b_points)
1164
                )
1165
            ) AS sum FROM matches WHERE status='entered' AND (FIND_IN_SET(?, team_a_players) OR FIND_IN_SET(?, team_b_players))",
1166
            array_fill(0, 5, $this->id)
1167
        );
1168
1169
        return $query[0]['sum'] / $count;
1170
    }
1171
1172
    /**
1173
     * Get the match activity in matches per day for a player
1174
     *
1175
     * @return float
1176
     */
1177
    public function getMatchActivity()
1178
    {
1179
        if ($this->matchActivity !== null) {
1180
            return $this->matchActivity;
1181
        }
1182
1183
        $activity = 0.0;
1184
1185
        $matches = Match::getQueryBuilder()
1186
            ->active()
1187
            ->with($this)
1188
            ->where('time')->isAfter(TimeDate::from('45 days ago'))
1189
            ->getModels($fast = true);
1190
1191
        foreach ($matches as $match) {
1192
            $activity += $match->getActivity();
1193
        }
1194
1195
        return $activity;
1196
    }
1197
1198
    /**
1199
     * Return an array of matches this player participated in per month.
1200
     *
1201
     * ```
1202
     * ['yyyy-mm'] = <number of matches>
1203
     * ```
1204
     *
1205
     * @param TimeDate|string $timePeriod
1206
     *
1207
     * @return int[]
1208
     */
1209
    public function getMatchSummary($timePeriod = '1 year ago')
1210
    {
1211
        $since = ($timePeriod instanceof TimeDate) ? $timePeriod : TimeDate::from($timePeriod);
1212
1213
        if (!isset($this->cachedMatchSummary[(string)$timePeriod])) {
1214
            $this->cachedMatchSummary[(string)$timePeriod] = Match::getQueryBuilder()
1215
                ->active()
1216
                ->with($this)
1217
                ->where('time')->isAfter($since)
1218
                ->getSummary($since)
1219
            ;
1220
        }
1221
1222
        return $this->cachedMatchSummary[(string)$timePeriod];
1223
    }
1224
1225
    /**
1226
     * Show the number of messages the user hasn't read yet
1227
     * @return int
1228
     */
1229 1
    public function countUnreadMessages()
1230
    {
1231 1
        return $this->fetchCount("WHERE `player` = ? AND `read` = 0",
1232 1
            $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...
1233
        );
1234
    }
1235
1236
    /**
1237
     * Get all of the members belonging to a team
1238
     * @param  int      $teamID The ID of the team to fetch the members of
1239
     * @return Player[] An array of Player objects of the team members
1240
     */
1241 2
    public static function getTeamMembers($teamID)
1242
    {
1243 2
        return self::arrayIdToModel(
1244 2
            self::fetchIds("WHERE team = ?", array($teamID))
1245
        );
1246
    }
1247
1248
    /**
1249
     * {@inheritdoc}
1250
     */
1251 1
    public static function getActiveStatuses()
1252
    {
1253 1
        return array('active', 'reported', 'test');
1254
    }
1255
1256
    /**
1257
     * {@inheritdoc}
1258
     */
1259 76
    public static function getEagerColumns($prefix = null)
1260
    {
1261
        $columns = [
1262 76
            'id',
1263
            'bzid',
1264
            'team',
1265
            'username',
1266
            'alias',
1267
            'status',
1268
            'avatar',
1269
            'country',
1270
        ];
1271
1272 76
        return self::formatColumns($prefix, $columns);
1273
    }
1274
1275
    /**
1276
     * {@inheritdoc}
1277
     */
1278 76
    public static function getLazyColumns($prefix = null)
1279
    {
1280
        $columns = [
1281 76
            'email',
1282
            'verified',
1283
            'receives',
1284
            'confirm_code',
1285
            'outdated',
1286
            'description',
1287
            'timezone',
1288
            'joined',
1289
            'last_login',
1290
            'last_match',
1291
            'admin_notes',
1292
        ];
1293
1294 76
        return self::formatColumns($prefix, $columns);
1295
    }
1296
1297
    /**
1298
     * Get a query builder for players
1299
     * @return PlayerQueryBuilder
1300
     */
1301
    public static function getQueryBuilder()
1302
    {
1303
        return new PlayerQueryBuilder('Player', array(
1304
            'columns' => array(
1305
                'name'     => 'username',
1306
                'team'     => 'team',
1307
                'outdated' => 'outdated',
1308
                'status'   => 'status',
1309
            ),
1310
            'name' => 'name',
1311
        ));
1312
    }
1313
1314
    /**
1315
     * Enter a new player to the database
1316
     * @param  int              $bzid        The player's bzid
1317
     * @param  string           $username    The player's username
1318
     * @param  int              $team        The player's team
1319
     * @param  string           $status      The player's status
1320
     * @param  int              $role_id     The player's role when they are first created
1321
     * @param  string           $avatar      The player's profile avatar
1322
     * @param  string           $description The player's profile description
1323
     * @param  int              $country     The player's country
1324
     * @param  string           $timezone    The player's timezone
1325
     * @param  string|\TimeDate $joined      The date the player joined
1326
     * @param  string|\TimeDate $last_login  The timestamp of the player's last login
1327
     * @return Player           An object representing the player that was just entered
1328
     */
1329 76
    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")
1330
    {
1331 76
        $joined = TimeDate::from($joined);
1332 76
        $last_login = TimeDate::from($last_login);
1333 76
        $timezone = ($timezone) ?: date_default_timezone_get();
1334
1335 76
        $player = self::create(array(
1336 76
            'bzid'        => $bzid,
1337 76
            'team'        => $team,
1338 76
            'username'    => $username,
1339 76
            'alias'       => self::generateAlias($username),
1340 76
            'status'      => $status,
1341 76
            'avatar'      => $avatar,
1342 76
            'description' => $description,
1343 76
            'country'     => $country,
1344 76
            'timezone'    => $timezone,
1345 76
            'joined'      => $joined->toMysql(),
1346 76
            'last_login'  => $last_login->toMysql(),
1347
        ));
1348
1349 76
        $player->addRole($role_id);
1350 76
        $player->getIdenticon($player->getId());
1351 76
        $player->setUsername($username);
1352
1353 76
        return $player;
1354
    }
1355
1356
    /**
1357
     * Determine if a player exists in the database
1358
     * @param  int  $bzid The player's bzid
1359
     * @return bool Whether the player exists in the database
1360
     */
1361
    public static function playerBZIDExists($bzid)
1362
    {
1363
        return self::getFromBZID($bzid)->isValid();
1364
    }
1365
1366
    /**
1367
     * Change a player's callsign and add it to the database if it does not
1368
     * exist as a past callsign
1369
     *
1370
     * @param  string $username The new username of the player
1371
     * @return self
1372
     */
1373 76
    public function setUsername($username)
1374
    {
1375
        // The player's username was just fetched from BzDB, it's definitely not
1376
        // outdated
1377 76
        $this->setOutdated(false);
1378
1379
        // Players who have this player's username are considered outdated
1380 76
        $this->db->execute("UPDATE {$this->table} SET outdated = 1 WHERE username = ? AND id != ?", array($username, $this->id));
1381
1382 76
        if ($username === $this->name) {
1383
            // The player's username hasn't changed, no need to do anything
1384 76
            return $this;
1385
        }
1386
1387
        // Players who used to have our player's username are not outdated anymore,
1388
        // unless they are more than one.
1389
        // Even though we are sure that the old and new usernames are not equal,
1390
        // MySQL makes a different type of string equality tests, which is why we
1391
        // also check IDs to make sure not to affect our own player's outdatedness.
1392
        $this->db->execute("
1393
            UPDATE {$this->table} SET outdated =
1394
                (SELECT (COUNT(*)>1) FROM (SELECT 1 FROM {$this->table} WHERE username = ? AND id != ?) t)
1395
            WHERE username = ? AND id != ?",
1396
            array($this->name, $this->id, $this->name, $this->id));
1397
1398
        $this->updateProperty($this->name, 'username', $username);
1399
        $this->db->execute("INSERT IGNORE INTO past_callsigns (player, username) VALUES (?, ?)", array($this->id, $username));
1400
        $this->resetAlias();
1401
1402
        return $this;
1403
    }
1404
1405
    /**
1406
     * Alphabetical order function for use in usort (case-insensitive)
1407
     * @return Closure The sort function
1408
     */
1409
    public static function getAlphabeticalSort()
1410
    {
1411 1
        return function (Player $a, Player $b) {
1412 1
            return strcasecmp($a->getUsername(), $b->getUsername());
1413 1
        };
1414
    }
1415
1416
    /**
1417
     * {@inheritdoc}
1418
     * @todo Add a constraint that does this automatically
1419
     */
1420 76
    public function wipe()
1421
    {
1422 76
        $this->db->execute("DELETE FROM past_callsigns WHERE player = ?", $this->id);
1423
1424 76
        parent::wipe();
1425 76
    }
1426
1427
    /**
1428
     * Find whether the player can delete a model
1429
     *
1430
     * @param  PermissionModel $model       The model that will be seen
1431
     * @param  bool         $showDeleted Whether to show deleted models to admins
1432
     * @return bool
1433
     */
1434 1
    public function canSee($model, $showDeleted = false)
1435
    {
1436 1
        return $model->canBeSeenBy($this, $showDeleted);
1437
    }
1438
1439
    /**
1440
     * Find whether the player can delete a model
1441
     *
1442
     * @param  PermissionModel $model The model that will be deleted
1443
     * @param  bool         $hard  Whether to check for hard-delete perms, as opposed
1444
     *                                to soft-delete ones
1445
     * @return bool
1446
     */
1447 1
    public function canDelete($model, $hard = false)
1448
    {
1449 1
        if ($hard) {
1450
            return $model->canBeHardDeletedBy($this);
1451
        } else {
1452 1
            return $model->canBeSoftDeletedBy($this);
1453
        }
1454
    }
1455
1456
    /**
1457
     * Find whether the player can create a model
1458
     *
1459
     * @param  string  $modelName The PHP class identifier of the model type
1460
     * @return bool
1461
     */
1462 1
    public function canCreate($modelName)
1463
    {
1464 1
        return $modelName::canBeCreatedBy($this);
1465
    }
1466
1467
    /**
1468
     * Find whether the player can edit a model
1469
     *
1470
     * @param  PermissionModel $model The model which will be edited
1471
     * @return bool
1472
     */
1473 1
    public function canEdit($model)
1474
    {
1475 1
        return $model->canBeEditedBy($this);
1476
    }
1477
}
1478