Completed
Push — master ( 80fdf3...351fbc )
by Vladimir
02:43
created

models/Player.php (1 issue)

Severity

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

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
        $seasonInfo = Season::getSeason($match->getTimestamp());
361
        $seasonKey = $this->buildSeasonKeyFromTimestamp($match->getTimestamp());
362
        $seasonElo = null;
0 ignored issues
show
$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...
363
364
        $this->getEloSeasonHistory($seasonInfo['season'], $seasonInfo['year']);
365
366
        if (!isset($this->eloSeasonHistory[$seasonKey][$match->getId()])) {
367
            return;
368
        }
369 30
370
        $eloChangelogIndex = array_search($match->getId(), array_keys($this->eloSeasonHistory[$seasonKey]));
371 30
        $slicedChangeLog = array_slice($this->eloSeasonHistory[$seasonKey], 0, $eloChangelogIndex, true);
372
373
        $this->eloSeasonHistory[$seasonKey] = $slicedChangeLog;
374 30
        $this->eloSeason[$seasonKey] = end($slicedChangeLog)['elo'];
375 29
    }
376
377
    /**
378 30
     * Get the Elo changes for a player for a given season
379
     *
380
     * @param  string|null $season The season to get
381
     * @param  int|null    $year   The year of the season to get
382
     *
383
     * @return array
384
     */
385
    public function getEloSeasonHistory($season = null, $year = null)
386
    {
387
        $seasonKey = $this->buildSeasonKey($season, $year);
388
389
        // This season's already been cached
390
        if (isset($this->eloSeasonHistory[$seasonKey])) {
391
            return $this->eloSeasonHistory[$seasonKey];
392 30
        }
393
394 30
        $result = $this->db->query('
395 30
          SELECT
396
            elo_new AS elo,
397 30
            match_id AS `match`,
398 30
            MONTH(matches.timestamp) AS `month`,
399 30
            YEAR(matches.timestamp) AS `year`,
400
            DAY(matches.timestamp) AS `day`
401
          FROM
402 30
            player_elo
403
            LEFT JOIN matches ON player_elo.match_id = matches.id
404
          WHERE
405
            user_id = ? AND season_period = ? AND season_year = ?
406
          ORDER BY
407
            match_id ASC
408
        ', [ $this->getId(), $season, $year ]);
409
410
        $this->eloSeasonHistory[$seasonKey] = [[
411
            'elo' => 1200,
412
            'match' => null,
413
            'month' => Season::getCurrentSeasonRange($season)->getStartOfRange()->month,
414
            'year' => $year,
415 30
            'day' => 1
416
        ]] + array_combine(array_column($result, 'match'), $result);
417 30
418 30
        return $this->eloSeasonHistory[$seasonKey];
419
    }
420 30
421 29
    /**
422
     * Get the player's Elo for a season.
423
     *
424 30
     * With the default arguments, it will fetch the Elo for the current season.
425
     *
426 30
     * @param string|null $season The season we're looking for: winter, spring, summer, or fall
427 30
     * @param int|null    $year   The year of the season we're looking for
428 30
     *
429
     * @return int The player's Elo
430
     */
431
    public function getElo($season = null, $year = null)
432
    {
433 30
        // The Elo for this player has been forcefully set from a trusted database query, so just return that.
434
        if ($this->elo !== null) {
435
            return $this->elo;
436
        }
437
438
        $this->getEloSeasonHistory($season, $year);
439
        $seasonKey = $this->buildSeasonKey($season, $year);
440
441
        if (isset($this->eloSeason[$seasonKey])) {
442
            return $this->eloSeason[$seasonKey];
443
        }
444
445 30
        $season = &$this->eloSeasonHistory[$seasonKey];
446
447 30
        if (!empty($season)) {
448 30
            $elo = end($season);
449
            $this->eloSeason[$seasonKey] = ($elo !== false) ? $elo['elo'] : 1200;
450
        } else {
451 30
            $this->eloSeason[$seasonKey] = 1200;
452 30
        }
453
454 30
        return $this->eloSeason[$seasonKey];
455
    }
456 30
457 29
    /**
458
     * Adjust the Elo of a player for the current season based on a Match
459 29
     *
460
     * **Warning:** If $match is null, the Elo for the player will be modified but the value will not be persisted to
461 30
     * the database.
462
     *
463
     * @param int        $adjust The value to be added to the current ELO (negative to subtract)
464
     * @param Match|null $match  The match where this Elo change took place
465
     */
466
    public function adjustElo($adjust, Match $match = null)
467
    {
468
        $timestamp = ($match !== null) ? $match->getTimestamp() : (Carbon::now());
469
        $seasonInfo = Season::getSeason($timestamp);
470
471
        // Get the current Elo for the player, even if it's the default 1200. We need the value for adjusting
472
        $elo = $this->getElo($seasonInfo['season'], $seasonInfo['year']);
473
        $seasonKey = sprintf('%s-%s', $seasonInfo['year'], $seasonInfo['season']);
474
475
        $this->eloSeason[$seasonKey] += $adjust;
476
477
        if ($match !== null && $this->isValid()) {
478
            $this->eloSeasonHistory[$seasonKey][$match->getId()] = [
479
                'elo' => $this->eloSeason[$seasonKey],
480
                'match' => $match->getId(),
481
                'month' => $match->getTimestamp()->month,
482
                'year' => $match->getTimestamp()->year,
483
                'day' => null,
484
            ];
485
486
            $this->db->execute('
487
              INSERT INTO player_elo VALUES (?, ?, ?, ?, ?, ?)
488
            ', [ $this->getId(), $match->getId(), $seasonInfo['season'], $seasonInfo['year'], $elo, $this->eloSeason[$seasonKey] ]);
489
        }
490
    }
491
492
    /**
493
     * Returns whether the player has verified their e-mail address
494
     *
495
     * @return bool `true` for verified players
496
     */
497
    public function isVerified()
498
    {
499
        $this->lazyLoad();
500
501
        return $this->verified;
502
    }
503
504
    /**
505
     * Returns the confirmation code for the player's e-mail address verification
506 1
     *
507
     * @return string The player's confirmation code
508 1
     */
509
    public function getConfirmCode()
510 1
    {
511
        $this->lazyLoad();
512 1
513
        return $this->confirmCode;
514
    }
515
516
    /**
517
     * Returns what kind of events the player should be e-mailed about
518
     *
519
     * @return string The type of notifications
520
     */
521
    public function getReceives()
522
    {
523
        $this->lazyLoad();
524
525
        return $this->receives;
526
    }
527
528
    /**
529
     * Finds out whether the specified player wants and can receive an e-mail
530
     * message
531
     *
532
     * @param  string  $type
533
     * @return bool `true` if the player should be sent an e-mail
534
     */
535
    public function canReceive($type)
536
    {
537
        $this->lazyLoad();
538
539
        if (!$this->email || !$this->isVerified()) {
540
            // Unverified e-mail means the user will receive nothing
541
            return false;
542
        }
543
544
        if ($this->receives == 'everything') {
545
            return true;
546
        }
547
548
        return $this->receives == $type;
549
    }
550
551
    /**
552
     * Find out whether the specified confirmation code is correct
553
     *
554
     * This method protects against timing attacks
555
     *
556
     * @param  string $code The confirmation code to check
557
     * @return bool `true` for a correct e-mail verification code
558
     */
559
    public function isCorrectConfirmCode($code)
560
    {
561
        $this->lazyLoad();
562
563
        if ($this->confirmCode === null) {
564
            return false;
565
        }
566
567
        return StringUtils::equals($code, $this->confirmCode);
568
    }
569
570
    /**
571
     * Get the player's sanitized description
572
     * @return string The description
573
     */
574
    public function getDescription()
575
    {
576
        $this->lazyLoad();
577
578
        return $this->description;
579
    }
580
581
    /**
582
     * Get the joined date of the player
583
     * @return TimeDate The joined date of the player
584
     */
585
    public function getJoinedDate()
586
    {
587
        $this->lazyLoad();
588
589
        return $this->joined->copy();
590
    }
591
592
    /**
593
     * Get all of the known IPs used by the player
594
     *
595
     * @return string[][] An array containing IPs and hosts
596
     */
597
    public function getKnownIPs()
598
    {
599
        return $this->db->query(
600
            'SELECT DISTINCT ip, host FROM visits WHERE player = ? GROUP BY ip, host ORDER BY MAX(timestamp) DESC LIMIT 10',
601
            array($this->getId())
602
        );
603
    }
604
605
    /**
606
     * Get the last login for a player
607
     * @return TimeDate The date of the last login
608
     */
609
    public function getLastLogin()
610
    {
611 23
        $this->lazyLoad();
612
613 23
        return $this->last_login->copy();
614
    }
615
616
    /**
617
     * Get the last match
618
     * @return Match
619
     */
620 1
    public function getLastMatch()
621
    {
622 1
        $this->lazyLoad();
623
624 1
        return $this->last_match;
625
    }
626
627
    /**
628
     * Get all of the callsigns a player has used to log in to the website
629
     * @return string[] An array containing all of the past callsigns recorded for a player
630
     */
631
    public function getPastCallsigns()
632
    {
633
        return self::fetchIds("WHERE player = ?", array($this->id), "past_callsigns", "username");
634
    }
635
636
    /**
637
     * Get the player's team
638
     * @return Team The object representing the team
639
     */
640
    public function getTeam()
641 76
    {
642
        return Team::get($this->team);
643 76
    }
644 76
645
    /**
646 76
     * Get the player's timezone PHP identifier (example: "Europe/Paris")
647 76
     * @return string The timezone
648
     */
649 76
    public function getTimezone()
650
    {
651
        $this->lazyLoad();
652
653
        return ($this->timezone) ?: date_default_timezone_get();
654
    }
655
656
    /**
657
     * Get the roles of the player
658 2
     * @return Role[]
659
     */
660 2
    public function getRoles()
661 1
    {
662
        $this->lazyLoad();
663
664 2
        return $this->roles;
665
    }
666 2
667
    /**
668
     * Rebuild the list of permissions a user has been granted
669
     */
670
    private function updateUserPermissions()
671
    {
672
        $this->roles = Role::getRoles($this->id);
673
        $this->permissions = array();
674
675
        foreach ($this->roles as $role) {
676
            $this->permissions = array_merge($this->permissions, $role->getPerms());
677
        }
678
    }
679
680
    /**
681
     * Check if a player has a specific permission
682
     *
683
     * @param string|null $permission The permission to check for
684
     *
685
     * @return bool Whether or not the player has the permission
686
     */
687
    public function hasPermission($permission)
688
    {
689
        if ($permission === null) {
690
            return false;
691
        }
692
693
        $this->lazyLoad();
694
695
        return isset($this->permissions[$permission]);
696
    }
697
698
    /**
699
     * Check whether or not a player been in a match or has logged on in the specified amount of time to be considered
700
     * active
701
     *
702
     * @return bool True if the player has been active
703
     */
704
    public function hasBeenActive()
705
    {
706
        $this->lazyLoad();
707
708
        $interval  = Service::getParameter('bzion.miscellaneous.active_interval');
709
        $lastLogin = $this->last_login->copy()->modify($interval);
710
711
        $hasBeenActive = (TimeDate::now() <= $lastLogin);
712
713
        if ($this->last_match->isValid()) {
714
            $lastMatch = $this->last_match->getTimestamp()->copy()->modify($interval);
715
            $hasBeenActive = ($hasBeenActive || TimeDate::now() <= $lastMatch);
716
        }
717
718
        return $hasBeenActive;
719
    }
720
721
    /**
722 1
     * Check whether the callsign of the player is outdated
723
     *
724 1
     * Returns true if this player has probably changed their callsign, making
725
     * the current username stored in the database obsolete
726
     *
727
     * @return bool Whether or not the player is disabled
728
     */
729
    public function isOutdated()
730
    {
731
        $this->lazyLoad();
732 55
733
        return $this->outdated;
734 55
    }
735
736
    /**
737
     * Check if a player's account has been disabled
738
     *
739
     * @return bool Whether or not the player is disabled
740 1
     */
741
    public function isDisabled()
742 1
    {
743
        return $this->status == "disabled";
744
    }
745
746 1
    /**
747
     * Check if everyone can log in as this user on a test environment
748
     *
749
     * @return bool
750
     */
751
    public function isTestUser()
752
    {
753
        return $this->status == "test";
754
    }
755
756
    /**
757
     * Check if a player is teamless
758
     *
759
     * @return bool True if the player is teamless
760
     */
761
    public function isTeamless()
762
    {
763
        return empty($this->team);
764
    }
765
766 2
    /**
767
     * Mark a player's account as banned
768 2
     *
769
     * @deprecated The players table shouldn't have any indicators of banned status, the Bans table is the authoritative source
770
     */
771
    public function markAsBanned()
772
    {
773
        return;
774
    }
775
776
    /**
777
     * Mark a player's account as unbanned
778
     *
779
     * @deprecated The players table shouldn't have any indicators of banned status, the Bans table is the authoritative source
780
     */
781
    public function markAsUnbanned()
782
    {
783
        return;
784
    }
785
786
    /**
787
     * Find out if a player is hard banned
788
     *
789
     * @return bool
790
     */
791
    public function isBanned()
792
    {
793
        $ban = Ban::getBan($this->id);
794
795
        return ($ban !== null && !$ban->isSoftBan());
796
    }
797
798
    /**
799
     * Get the ban of the player
800
     *
801
     * This method performs a load of all the lazy parameters of the Player
802
     *
803
     * @return Ban|null The current ban of the player, or null if the player is
804
     *                  is not banned
805
     */
806
    public function getBan()
807
    {
808
        $this->lazyLoad();
809
810
        return $this->ban;
811
    }
812
813
    /**
814
     * Remove a player from a role
815
     *
816
     * @param int $role_id The role ID to add or remove
817
     *
818
     * @return bool Whether the operation was successful or not
819
     */
820
    public function removeRole($role_id)
821
    {
822
        $status = $this->modifyRole($role_id, "remove");
823
        $this->refresh();
824
825
        return $status;
826
    }
827
828
    /**
829
     * Set the player's email address and reset their verification status
830
     * @param string $email The address
831
     */
832
    public function setEmailAddress($email)
833
    {
834
        $this->lazyLoad();
835
836
        if ($this->email == $email) {
837
            // The e-mail hasn't changed, don't do anything
838
            return;
839
        }
840
841
        $this->setVerified(false);
842
        $this->generateNewConfirmCode();
843
844
        $this->updateProperty($this->email, 'email', $email);
845
    }
846
847
    /**
848
     * Set whether the player has verified their e-mail address
849
     *
850
     * @param  bool $verified Whether the player is verified or not
851
     * @return self
852
     */
853
    public function setVerified($verified)
854
    {
855
        $this->lazyLoad();
856
857
        if ($verified) {
858
            $this->setConfirmCode(null);
859
        }
860
861
        return $this->updateProperty($this->verified, 'verified', $verified);
862
    }
863
864
    /**
865
     * Generate a new random confirmation token for e-mail address verification
866
     *
867
     * @return self
868
     */
869
    public function generateNewConfirmCode()
870
    {
871
        $generator = new SecureRandom();
872
        $random = $generator->nextBytes(16);
873
874
        return $this->setConfirmCode(bin2hex($random));
875
    }
876
877
    /**
878
     * Set the confirmation token for e-mail address verification
879
     *
880
     * @param  string $code The confirmation code
881
     * @return self
882 76
     */
883
    private function setConfirmCode($code)
884 76
    {
885
        $this->lazyLoad();
886 76
887
        return $this->updateProperty($this->confirmCode, 'confirm_code', $code);
888
    }
889
890
    /**
891
     * Set what kind of events the player should be e-mailed about
892
     *
893
     * @param  string $receives The type of notification
894
     * @return self
895
     */
896
    public function setReceives($receives)
897
    {
898
        $this->lazyLoad();
899
900
        return $this->updateProperty($this->receives, 'receives', $receives);
901
    }
902
903
    /**
904
     * Set whether the callsign of the player is outdated
905
     *
906
     * @param  bool $outdated Whether the callsign is outdated
907
     * @return self
908
     */
909
    public function setOutdated($outdated)
910
    {
911 55
        $this->lazyLoad();
912
913 55
        return $this->updateProperty($this->outdated, 'outdated', $outdated);
914 55
    }
915
916
    /**
917
     * Set the player's description
918
     * @param string $description The description
919
     */
920
    public function setDescription($description)
921 29
    {
922
        $this->updateProperty($this->description, "description", $description);
923 29
    }
924 29
925
    /**
926
     * Set the player's timezone
927
     * @param string $timezone The timezone
928
     */
929
    public function setTimezone($timezone)
930
    {
931
        $this->updateProperty($this->timezone, "timezone", $timezone);
932
    }
933
934
    /**
935
     * Set the player's team
936
     * @param int $team The team's ID
937
     */
938
    public function setTeam($team)
939
    {
940
        $this->updateProperty($this->team, "team", $team);
941
    }
942
943
    /**
944
     * Set the match the player last participated in
945
     *
946
     * @param int $match The match's ID
947
     */
948
    public function setLastMatch($match)
949
    {
950
        $this->updateProperty($this->last_match, 'last_match', $match);
951
    }
952
953
    /**
954
     * Set the player's status
955
     * @param string $status The new status
956
     */
957
    public function setStatus($status)
958
    {
959
        $this->updateProperty($this->status, 'status', $status);
960
    }
961
962
    /**
963
     * Set the player's admin notes
964
     * @param  string $admin_notes The new admin notes
965
     * @return self
966
     */
967 1
    public function setAdminNotes($admin_notes)
968
    {
969 1
        return $this->updateProperty($this->admin_notes, 'admin_notes', $admin_notes);
970
    }
971
972
    /**
973
     * Set the player's country
974
     * @param  int   $country The ID of the new country
975
     * @return self
976 1
     */
977
    public function setCountry($country)
978 1
    {
979
        return $this->updateProperty($this->country, 'country', $country);
980
    }
981
982
    /**
983
     * Get the player's chosen theme preference
984
     *
985
     * @return string
986
     */
987
    public function getTheme()
988
    {
989
        $this->lazyLoad();
990
991
        return $this->theme;
992
    }
993
994
    /**
995
     * Set the site theme for the player
996
     *
997
     * If the chosen site theme is invalid, it'll be defaulted to the site default (the first theme defined)
998
     *
999
     * @param string $theme
1000
     */
1001
    public function setTheme($theme)
1002
    {
1003
        $themes = array_column(Service::getSiteThemes(), 'slug');
1004
1005
        if (!in_array($theme, $themes)) {
1006
            $theme = Service::getDefaultSiteTheme();
1007
        }
1008
1009
        return $this->updateProperty($this->theme, 'theme', $theme);
1010
    }
1011
1012
    /**
1013
     * Whether or not this player has color blind assistance enabled.
1014
     *
1015
     * @return bool
1016
     */
1017
    public function hasColorBlindAssist()
1018
    {
1019
        $this->lazyLoad();
1020
1021
        return (bool)$this->color_blind_enabled;
1022
    }
1023
1024
    /**
1025
     * Set a player's setting for color blind assistance.
1026
     *
1027
     * @param bool $enabled
1028
     *
1029
     * @return self
1030
     */
1031
    public function setColorBlindAssist($enabled)
1032
    {
1033
        return $this->updateProperty($this->color_blind_enabled, 'color_blind_enabled', $enabled);
1034
    }
1035
1036
    /**
1037
     * Updates this player's last login
1038
     */
1039
    public function updateLastLogin()
1040
    {
1041
        $this->update("last_login", TimeDate::now()->toMysql());
1042
    }
1043
1044 76
    /**
1045
     * Get the player's username
1046 76
     * @return string The username
1047
     */
1048 76
    public function getUsername()
1049 76
    {
1050 76
        return $this->name;
1051
    }
1052
1053
    /**
1054
     * Get the player's username, safe for use in your HTML
1055
     * @return string The username
1056
     */
1057 76
    public function getEscapedUsername()
1058
    {
1059
        return $this->getEscapedName();
1060
    }
1061
1062
    /**
1063
     * Alias for Player::setUsername()
1064
     *
1065
     * @param  string $username The new username
1066
     * @return self
1067
     */
1068
    public function setName($username)
1069 23
    {
1070
        return $this->setUsername($username);
1071 23
    }
1072
1073
    /**
1074
     * Mark all the unread messages of a player as read
1075
     *
1076
     * @return void
1077
     */
1078
    public function markMessagesAsRead()
1079
    {
1080 1
        $this->db->execute(
1081
            "UPDATE `player_conversations` SET `read` = 1 WHERE `player` = ? AND `read` = 0",
1082 1
            array($this->id)
1083
        );
1084 1
    }
1085
1086
    /**
1087
     * Set the roles of a user
1088
     *
1089
     * @todo   Is it worth making this faster?
1090
     * @param  Role[] $roles The new roles of the user
1091
     * @return self
1092
     */
1093
    public function setRoles($roles)
1094
    {
1095
        $this->lazyLoad();
1096
1097
        $oldRoles = Role::mapToIds($this->roles);
1098
        $this->roles = $roles;
1099
        $roleIds = Role::mapToIds($roles);
1100
1101
        $newRoles     = array_diff($roleIds, $oldRoles);
1102 1
        $removedRoles = array_diff($oldRoles, $roleIds);
1103
1104 1
        foreach ($newRoles as $role) {
1105
            $this->modifyRole($role, 'add');
1106
        }
1107
1108
        foreach ($removedRoles as $role) {
1109
            $this->modifyRole($role, 'remove');
1110
        }
1111
1112
        $this->refresh();
1113
1114
        return $this;
1115
    }
1116
1117
    /**
1118
     * Give or remove a role to/form a player
1119
     *
1120
     * @param int    $role_id The role ID to add or remove
1121
     * @param string $action  Whether to "add" or "remove" a role for a player
1122
     *
1123
     * @return bool Whether the operation was successful or not
1124
     */
1125
    private function modifyRole($role_id, $action)
1126
    {
1127
        $role = Role::get($role_id);
1128
1129
        if ($role->isValid()) {
1130
            if ($action == "add") {
1131
                $this->db->execute("INSERT INTO player_roles (user_id, role_id) VALUES (?, ?)", array($this->getId(), $role_id));
1132
            } elseif ($action == "remove") {
1133
                $this->db->execute("DELETE FROM player_roles WHERE user_id = ? AND role_id = ?", array($this->getId(), $role_id));
1134
            } else {
1135
                throw new Exception("Unrecognized role action");
1136
            }
1137
1138
            return true;
1139
        }
1140
1141
        return false;
1142
    }
1143
1144
    /**
1145
     * Given a player's BZID, get a player object
1146
     *
1147
     * @param  int    $bzid The player's BZID
1148
     * @return Player
1149
     */
1150
    public static function getFromBZID($bzid)
1151
    {
1152
        return self::get(self::fetchIdFrom($bzid, "bzid"));
1153
    }
1154
1155
    /**
1156
     * Get a single player by their username
1157
     *
1158
     * @param  string $username The username to look for
1159
     * @return Player
1160
     */
1161
    public static function getFromUsername($username)
1162
    {
1163
        $player = static::get(self::fetchIdFrom($username, 'username'));
1164
1165
        return $player->inject('name', $username);
1166
    }
1167
1168
    /**
1169
     * Get all the players in the database that have an active status
1170
     * @return Player[] An array of player BZIDs
1171
     */
1172
    public static function getPlayers()
1173
    {
1174
        return self::arrayIdToModel(
1175
            self::fetchIdsFrom("status", array("active", "test"), false)
1176
        );
1177
    }
1178
1179
    /**
1180
     * Show the number of notifications the user hasn't read yet
1181
     * @return int
1182
     */
1183
    public function countUnreadNotifications()
1184
    {
1185
        return Notification::countUnreadNotifications($this->id);
1186
    }
1187
1188
    /**
1189
     * Count the number of matches a player has participated in
1190
     * @return int
1191
     */
1192
    public function getMatchCount()
1193
    {
1194
        if ($this->cachedMatchCount === null) {
1195
            $this->cachedMatchCount = Match::getQueryBuilder()
1196
                ->active()
1197
                ->with($this)
1198
                ->count();
1199
        }
1200
1201
        return $this->cachedMatchCount;
1202
    }
1203
1204
    /**
1205
     * Get the (victory/total matches) ratio of the player
1206
     * @return float
1207
     */
1208
    public function getMatchWinRatio()
1209
    {
1210
        $count = $this->getMatchCount();
1211
1212
        if ($count == 0) {
1213
            return 0;
1214
        }
1215
1216
        $wins = Match::getQueryBuilder()
1217
            ->active()
1218
            ->with($this, 'win')
1219
            ->count();
1220
1221
        return $wins / $count;
1222
    }
1223
1224
    /**
1225
     * Get the (total caps made by team/total matches) ratio of the player
1226
     * @return float
1227
     */
1228
    public function getMatchAverageCaps()
1229 1
    {
1230
        $count = $this->getMatchCount();
1231 1
1232 1
        if ($count == 0) {
1233
            return 0;
1234
        }
1235
1236
        // Get the sum of team A points if the player was in team A, team B points if the player was in team B
1237
        $query = $this->db->query("
1238
            SELECT
1239
              SUM(
1240
                IF(mp.team_loyalty = 0, team_a_points, team_b_points)
1241 2
              ) AS sum
1242
            FROM
1243 2
              matches
1244 2
            INNER JOIN
1245
              match_participation mp ON mp.match_id = matches.id
1246
            WHERE
1247
              status = 'entered' AND mp.user_id = ?
1248
        ", [$this->id]);
1249
1250
        return $query[0]['sum'] / $count;
1251 1
    }
1252
1253 1
    /**
1254
     * Get the match activity in matches per day for a player
1255
     *
1256
     * @return float
1257
     */
1258
    public function getMatchActivity()
1259 76
    {
1260
        if ($this->matchActivity !== null) {
1261
            return $this->matchActivity;
1262 76
        }
1263
1264
        $activity = 0.0;
1265
1266
        $matches = Match::getQueryBuilder()
1267
            ->active()
1268
            ->with($this)
1269
            ->where('time')->isAfter(TimeDate::from('45 days ago'))
1270
            ->getModels($fast = true);
1271
1272 76
        foreach ($matches as $match) {
1273
            $activity += $match->getActivity();
1274
        }
1275
1276
        return $activity;
1277
    }
1278 76
1279
    /**
1280
     * Return an array of matches this player participated in per month.
1281 76
     *
1282
     * ```
1283
     * ['yyyy-mm'] = <number of matches>
1284
     * ```
1285
     *
1286
     * @param TimeDate|string $timePeriod
1287
     *
1288
     * @return int[]
1289
     */
1290
    public function getMatchSummary($timePeriod = '1 year ago')
1291
    {
1292
        $since = ($timePeriod instanceof TimeDate) ? $timePeriod : TimeDate::from($timePeriod);
1293
1294 76
        if (!isset($this->cachedMatchSummary[(string)$timePeriod])) {
1295
            $this->cachedMatchSummary[(string)$timePeriod] = Match::getQueryBuilder()
1296
                ->active()
1297
                ->with($this)
1298
                ->where('time')->isAfter($since)
1299
                ->getSummary($since)
1300
            ;
1301
        }
1302
1303
        return $this->cachedMatchSummary[(string)$timePeriod];
1304
    }
1305
1306
    /**
1307
     * Show the number of messages the user hasn't read yet
1308
     * @return int
1309
     */
1310
    public function countUnreadMessages()
1311
    {
1312
        return $this->fetchCount("WHERE `player` = ? AND `read` = 0",
1313
            $this->id, 'player_conversations'
1314
        );
1315
    }
1316
1317
    /**
1318
     * Get all of the members belonging to a team
1319
     * @param  int      $teamID The ID of the team to fetch the members of
1320
     * @return Player[] An array of Player objects of the team members
1321
     */
1322
    public static function getTeamMembers($teamID)
1323
    {
1324
        return self::arrayIdToModel(
1325
            self::fetchIds("WHERE team = ?", array($teamID))
1326
        );
1327
    }
1328
1329 76
    /**
1330
     * {@inheritdoc}
1331 76
     */
1332 76
    public static function getActiveStatuses()
1333 76
    {
1334
        return array('active', 'reported', 'test');
1335 76
    }
1336 76
1337 76
    /**
1338 76
     * {@inheritdoc}
1339 76
     */
1340 76
    public static function getEagerColumns($prefix = null)
1341 76
    {
1342 76
        $columns = [
1343 76
            'id',
1344 76
            'bzid',
1345 76
            'team',
1346 76
            'username',
1347
            'alias',
1348
            'status',
1349 76
            'avatar',
1350 76
            'country',
1351 76
        ];
1352
1353 76
        return self::formatColumns($prefix, $columns);
1354
    }
1355
1356
    /**
1357
     * {@inheritdoc}
1358
     */
1359
    public static function getLazyColumns($prefix = null)
1360
    {
1361
        $columns = [
1362
            'email',
1363
            'verified',
1364
            'receives',
1365
            'confirm_code',
1366
            'outdated',
1367
            'description',
1368
            'theme',
1369
            'color_blind_enabled',
1370
            'timezone',
1371
            'joined',
1372
            'last_login',
1373 76
            'last_match',
1374
            'admin_notes',
1375
        ];
1376
1377 76
        return self::formatColumns($prefix, $columns);
1378
    }
1379
1380 76
    /**
1381
     * Get a query builder for players
1382 76
     * @return PlayerQueryBuilder
1383
     */
1384 76
    public static function getQueryBuilder()
1385
    {
1386
        return new PlayerQueryBuilder('Player', array(
1387
            'columns' => array(
1388
                'name'     => 'username',
1389
                'team'     => 'team',
1390
                'outdated' => 'outdated',
1391
                'status'   => 'status',
1392
            ),
1393
            'name' => 'name',
1394
        ));
1395
    }
1396
1397
    /**
1398
     * Enter a new player to the database
1399
     * @param  int              $bzid        The player's bzid
1400
     * @param  string           $username    The player's username
1401
     * @param  int              $team        The player's team
1402
     * @param  string           $status      The player's status
1403
     * @param  int              $role_id     The player's role when they are first created
1404
     * @param  string           $avatar      The player's profile avatar
1405
     * @param  string           $description The player's profile description
1406
     * @param  int              $country     The player's country
1407
     * @param  string           $timezone    The player's timezone
1408
     * @param  string|\TimeDate $joined      The date the player joined
1409
     * @param  string|\TimeDate $last_login  The timestamp of the player's last login
1410
     * @return Player           An object representing the player that was just entered
1411 1
     */
1412 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")
1413 1
    {
1414
        $joined = TimeDate::from($joined);
1415
        $last_login = TimeDate::from($last_login);
1416
        $timezone = ($timezone) ?: date_default_timezone_get();
1417
1418
        $player = self::create(array(
1419
            'bzid'        => $bzid,
1420 76
            'team'        => $team,
1421
            'username'    => $username,
1422 76
            'alias'       => self::generateAlias($username),
1423
            'status'      => $status,
1424 76
            'avatar'      => $avatar,
1425 76
            'description' => $description,
1426
            'country'     => $country,
1427
            'timezone'    => $timezone,
1428
            'joined'      => $joined->toMysql(),
1429
            'last_login'  => $last_login->toMysql(),
1430
        ));
1431
1432
        $player->addRole($role_id);
1433
        $player->getIdenticon($player->getId());
1434 1
        $player->setUsername($username);
1435
1436 1
        return $player;
1437
    }
1438
1439
    /**
1440
     * Determine if a player exists in the database
1441
     * @param  int  $bzid The player's bzid
1442
     * @return bool Whether the player exists in the database
1443
     */
1444
    public static function playerBZIDExists($bzid)
1445
    {
1446
        return self::getFromBZID($bzid)->isValid();
1447 1
    }
1448
1449 1
    /**
1450
     * Change a player's callsign and add it to the database if it does not
1451
     * exist as a past callsign
1452 1
     *
1453
     * @param  string $username The new username of the player
1454
     * @return self
1455
     */
1456
    public function setUsername($username)
1457
    {
1458
        // The player's username was just fetched from BzDB, it's definitely not
1459
        // outdated
1460
        $this->setOutdated(false);
1461
1462 1
        // Players who have this player's username are considered outdated
1463
        $this->db->execute("UPDATE {$this->table} SET outdated = 1 WHERE username = ? AND id != ?", array($username, $this->id));
1464 1
1465
        if ($username === $this->name) {
1466
            // The player's username hasn't changed, no need to do anything
1467
            return $this;
1468
        }
1469
1470
        // Players who used to have our player's username are not outdated anymore,
1471
        // unless they are more than one.
1472
        // Even though we are sure that the old and new usernames are not equal,
1473 1
        // MySQL makes a different type of string equality tests, which is why we
1474
        // also check IDs to make sure not to affect our own player's outdatedness.
1475 1
        $this->db->execute("
1476
            UPDATE {$this->table} SET outdated =
1477
                (SELECT (COUNT(*)>1) FROM (SELECT 1 FROM {$this->table} WHERE username = ? AND id != ?) t)
1478
            WHERE username = ? AND id != ?",
1479
            array($this->name, $this->id, $this->name, $this->id));
1480
1481
        $this->updateProperty($this->name, 'username', $username);
1482
        $this->db->execute("INSERT IGNORE INTO past_callsigns (player, username) VALUES (?, ?)", array($this->id, $username));
1483
        $this->resetAlias();
1484
1485
        return $this;
1486
    }
1487
1488
    /**
1489
     * Alphabetical order function for use in usort (case-insensitive)
1490
     * @return Closure The sort function
1491
     */
1492
    public static function getAlphabeticalSort()
1493
    {
1494
        return function (Player $a, Player $b) {
1495
            return strcasecmp($a->getUsername(), $b->getUsername());
1496
        };
1497
    }
1498
1499
    /**
1500
     * {@inheritdoc}
1501
     * @todo Add a constraint that does this automatically
1502
     */
1503
    public function wipe()
1504
    {
1505
        $this->db->execute("DELETE FROM past_callsigns WHERE player = ?", $this->id);
1506
1507
        parent::wipe();
1508
    }
1509
1510
    /**
1511
     * Find whether the player can delete a model
1512
     *
1513
     * @param  PermissionModel $model       The model that will be seen
1514
     * @param  bool         $showDeleted Whether to show deleted models to admins
1515
     * @return bool
1516
     */
1517
    public function canSee($model, $showDeleted = false)
1518
    {
1519
        return $model->canBeSeenBy($this, $showDeleted);
1520
    }
1521
1522
    /**
1523
     * Find whether the player can delete a model
1524
     *
1525
     * @param  PermissionModel $model The model that will be deleted
1526
     * @param  bool         $hard  Whether to check for hard-delete perms, as opposed
1527
     *                                to soft-delete ones
1528
     * @return bool
1529
     */
1530
    public function canDelete($model, $hard = false)
1531
    {
1532
        if ($hard) {
1533
            return $model->canBeHardDeletedBy($this);
1534
        } else {
1535
            return $model->canBeSoftDeletedBy($this);
1536
        }
1537
    }
1538
1539
    /**
1540
     * Find whether the player can create a model
1541
     *
1542
     * @param  string  $modelName The PHP class identifier of the model type
1543
     * @return bool
1544
     */
1545
    public function canCreate($modelName)
1546
    {
1547
        return $modelName::canBeCreatedBy($this);
1548
    }
1549
1550
    /**
1551
     * Find whether the player can edit a model
1552
     *
1553
     * @param  PermissionModel $model The model which will be edited
1554
     * @return bool
1555
     */
1556
    public function canEdit($model)
1557
    {
1558
        return $model->canBeEditedBy($this);
1559
    }
1560
}
1561