Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.45% covered (success)
90.45%
161 / 178
43.75% covered (danger)
43.75%
7 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
TimeMode
90.17% covered (success)
90.17%
156 / 173
43.75% covered (danger)
43.75%
7 / 16
57.87
0.00% covered (danger)
0.00%
0 / 1
 __construct
92.86% covered (success)
92.86%
26 / 28
0.00% covered (danger)
0.00%
0 / 1
9.03
 startTimeMode
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
3.14
 cancelTimeMode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 createNewSession
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
3.01
 getRatingBounds
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 getRelevantTsumegos
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
3.21
 createSessionAttempts
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 deduceAttemptStatus
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 processPlayResult
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
6.01
 skip
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
3.02
 prepareNextToSolve
82.61% covered (warning)
82.61%
19 / 23
0.00% covered (danger)
0.00%
0 / 1
5.13
 currentWillBeLast
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 checkFinishSession
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
7
 calculatePoints
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 exportTimeModeInfo
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getCurrentRank
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
1<?php
2
3App::uses('TimeModeUtil', 'Utility');
4App::uses('RatingBounds', 'Utility');
5App::uses('ForbiddenException', 'Routing/Error');
6App::uses('InternalErrorException', 'Routing/Error');
7App::uses('NotFoundException', 'Routing/Error');
8
9class TimeMode
10{
11    public function __construct()
12    {
13        if (!Auth::isInTimeMode())
14            return;
15
16        $this->currentSession = ClassRegistry::init('TimeModeSession')->find('first', [
17            'conditions' => [
18                'user_id' => Auth::getUserID(),
19                'time_mode_session_status_id' => TimeModeUtil::$SESSION_STATUS_IN_PROGRESS]]);
20
21        if (!$this->currentSession)
22            return;
23
24        // is TimeModeUtil::$PROBLEM_COUNT normally, but can be less when not enough problems found
25        $attempts =  ClassRegistry::init('TimeModeAttempt')->find('all', [
26            'conditions' => ['time_mode_session_id' => $this->currentSession['TimeModeSession']['id']]]);
27        foreach ($attempts as $attempt)
28        {
29            if ($attempt['TimeModeAttempt']['time_mode_attempt_status_id'] == TimeModeUtil::$ATTEMPT_RESULT_SOLVED)
30                $this->successCount++;
31            elseif (
32                $attempt['TimeModeAttempt']['time_mode_attempt_status_id'] == TimeModeUtil::$ATTEMPT_RESULT_FAILED
33                || $attempt['TimeModeAttempt']['time_mode_attempt_status_id'] == TimeModeUtil::$ATTEMPT_STATUS_SKIPPED
34                || $attempt['TimeModeAttempt']['time_mode_attempt_status_id'] == TimeModeUtil::$ATTEMPT_STATUS_TIMEOUT)
35                    $this->failCount++;
36        }
37        $this->overallCount = count($attempts);
38
39        if ($this->overallCount == 0)
40        {
41            $this->cancelTimeMode();
42            return;
43        }
44
45        $this->currentOrder = ClassRegistry::init('TimeModeAttempt')->find('count', [
46            'conditions' => [
47                'time_mode_session_id' => $this->currentSession['TimeModeSession']['id'],
48                'time_mode_attempt_status_id !=' => TimeModeUtil::$ATTEMPT_RESULT_QUEUED]]) + 1;
49
50        $this->rank = ClassRegistry::init('TimeModeRank')->findById($this->currentSession['TimeModeSession']['time_mode_rank_id']);
51        $this->overallSecondsToSolve = ClassRegistry::init('TimeModeCategory')->find('first', [
52            'conditions' => ['id' => $this->currentSession['TimeModeSession']['time_mode_category_id']]])['TimeModeCategory']['seconds'];
53    }
54
55    public function startTimeMode(int $categoryID, int $rankID): void
56    {
57        if (!Auth::isLoggedIn())
58            throw new ForbiddenException('Not logged in.');
59
60        ClassRegistry::init('TimeModeSession')->deleteAll(['user_id' => Auth::getUserID(), 'time_mode_session_status_id' => TimeModeUtil::$SESSION_STATUS_IN_PROGRESS]);
61
62        $relevantTsumegos = $this->getRelevantTsumegos($rankID);
63        if (empty($relevantTsumegos))
64            throw new InternalErrorException('No relevant tsumegos.');
65        $currentTimeSession = $this->createNewSession($categoryID, $rankID);
66        $this->createSessionAttempts($currentTimeSession, $relevantTsumegos);
67    }
68
69    public static function cancelTimeMode(): void
70    {
71        ClassRegistry::init('TimeModeSession')->deleteAll(['user_id' => Auth::getUserID(), 'time_mode_session_status_id' => TimeModeUtil::$SESSION_STATUS_IN_PROGRESS]);
72    }
73
74    private function createNewSession(int $categoryID, int $rankID): array
75    {
76        $timeModeCategory = ClassRegistry::init('TimeModeCategory')->findById($categoryID);
77        if (!$timeModeCategory)
78            throw new NotFoundException("Time mode session category with id=" . $categoryID . " not found");
79
80        $timeModeRank = ClassRegistry::init('TimeModeRank')->findById($rankID);
81        if (!$timeModeRank)
82            throw new NotFoundException("Time mode rank category with id=" . $rankID . " not found");
83
84        Auth::getUser()['mode'] = Constants::$TIME_MODE;
85        Auth::saveUser();
86        $currentTimeSession = [];
87        $currentTimeSession['user_id'] = Auth::getUserID();
88        $currentTimeSession['time_mode_session_status_id'] = TimeModeUtil::$SESSION_STATUS_IN_PROGRESS;
89        $currentTimeSession['time_mode_category_id'] = $timeModeCategory['TimeModeCategory']['id'];
90        $currentTimeSession['time_mode_rank_id'] = $rankID;
91        ClassRegistry::init('TimeModeSession')->create($currentTimeSession);
92        ClassRegistry::init('TimeModeSession')->save($currentTimeSession);
93        $currentTimeSession['id'] = ClassRegistry::init('TimeModeSession')->getLastInsertID();
94        return $currentTimeSession;
95    }
96
97    public static function getRatingBounds(?int $timeModeRankID): RatingBounds
98    {
99        $timeModeRankModel = ClassRegistry::init('TimeModeRank');
100
101        // I'm assuming, that the entries in the time_mode_rank tables have primary id ordered in the same order as the ranks, so the next entry
102        // is the higher rank, and the previous is the lower (checked in testTimeModeRankContentsIntegrity)
103        // This allows me to figure out what range should I cover by the current rank, which is
104        // <max_of_smaller_rank or 0 if it doesn't exit, max_of_current_rank if next rank exists or infinity]
105        // this should put every tsumego in some of the rank intervals regardless of the ranks configuration
106        $result = new RatingBounds();
107        if ($smallerRankRow = $timeModeRankModel->find('first', ['conditions' => ['id <' => $timeModeRankID], 'order' => 'id DESC']))
108            $result->min = Rating::getRankMinimalRating(Rating::GetRankFromReadableRank($smallerRankRow['TimeModeRank']['name']) + 1);
109
110        if ($timeModeRankModel->find('first', ['conditions' => ['id >' => $timeModeRankID], 'order' => 'id ASC']))
111        {
112            $currentRankRow = $timeModeRankModel->find('first', ['conditions' => ['id' => $timeModeRankID]]);
113            $result->max = Rating::getRankMinimalRating(Rating::GetRankFromReadableRank($currentRankRow['TimeModeRank']['name']) + 1);
114        }
115
116        return $result;
117    }
118
119    private function getRelevantTsumegos(int $timeModeRankID): array
120    {
121        $ratingBounds = $this->getRatingBounds($timeModeRankID);
122        $query = "SELECT tsumego.id as id FROM tsumego JOIN set_connection ON set_connection.tsumego_id = tsumego.id JOIN `set` ON set.id = set_connection.set_id WHERE `set`.included_in_time_mode = TRUE AND `set`.public = 1";
123        if ($ratingBounds->min)
124            $query .= " AND rating >= " . $ratingBounds->min;
125        if ($ratingBounds->max)
126            $query .= " AND rating < " . $ratingBounds->max;
127        return Util::query($query);
128    }
129
130    private function createSessionAttempts(array $currentTimeSession, $relevantTsumegos): void
131    {
132        shuffle($relevantTsumegos);
133        $relevantTsumegosCount = count($relevantTsumegos);
134        for ($i = 0; $i < TimeModeUtil::$PROBLEM_COUNT && $i < $relevantTsumegosCount; $i++)
135        {
136            $newTimeAttempt = [];
137            $newTimeAttempt['time_mode_session_id'] = $currentTimeSession['id'];
138            $newTimeAttempt['order'] = $i + 1;
139            $newTimeAttempt['tsumego_id'] = $relevantTsumegos[$i]['id'];
140            $newTimeAttempt['time_mode_attempt_status_id'] = TimeModeUtil::$ATTEMPT_RESULT_QUEUED;
141            ClassRegistry::init('TimeModeAttempt')->create($newTimeAttempt);
142            ClassRegistry::init('TimeModeAttempt')->save($newTimeAttempt);
143        }
144    }
145
146    private static function deduceAttemptStatus($result, $timeout)
147    {
148        if ($timeout)
149            return TimeModeUtil::$ATTEMPT_STATUS_TIMEOUT;
150        if (!$result['solved'])
151            return TimeModeUtil::$ATTEMPT_RESULT_FAILED;
152        return TimeModeUtil::$ATTEMPT_RESULT_SOLVED;
153    }
154
155    public function processPlayResult($previousTsumego, $result): void
156    {
157        if (!$this->currentSession)
158            return;
159        $currentAttempt = ClassRegistry::init('TimeModeAttempt')->find('first', [
160            'conditions' => [
161                'time_mode_session_id' => $this->currentSession['TimeModeSession']['id'],
162                'tsumego_id' => $previousTsumego['Tsumego']['id'],
163                'time_mode_attempt_status_id' => TimeModeUtil::$ATTEMPT_RESULT_QUEUED]]);
164        if (!$currentAttempt)
165            return; // this tsumego is not related to our time mode session, we ignore it
166        $timeout = Util::clearCookie('timeout');
167        $currentAttempt['TimeModeAttempt']['time_mode_attempt_status_id'] = self::deduceAttemptStatus($result, $timeout);
168        $seconds = $timeout ? $this->overallSecondsToSolve : Decoder::decodeSeconds($previousTsumego);
169        if (is_null($seconds))
170            throw new Exception("Seconds not provided.");
171        $timeModeCategory = ClassRegistry::init('TimeModeCategory')->findById($this->currentSession['TimeModeSession']['time_mode_category_id']);
172
173        $currentAttempt['TimeModeAttempt']['seconds'] = $seconds;
174        $currentAttempt['TimeModeAttempt']['points'] = $result['solved'] ? self::calculatePoints($seconds, $timeModeCategory['TimeModeCategory']['seconds']) : 0;
175        ClassRegistry::init('TimeModeAttempt')->save($currentAttempt);
176        $this->currentOrder++;
177    }
178
179    public function skip(): void
180    {
181        if (!$this->currentSession)
182            return;
183        $currentAttempt = ClassRegistry::init('TimeModeAttempt')->find('first', [
184            'conditions' => [
185                'time_mode_session_id' => $this->currentSession['TimeModeSession']['id'],
186                'started NOT' => null,
187                'time_mode_attempt_status_id' => TimeModeUtil::$ATTEMPT_RESULT_QUEUED]]);
188        if (!$currentAttempt)
189            return;
190        $currentAttempt['TimeModeAttempt']['time_mode_attempt_status_id'] = TimeModeUtil::$ATTEMPT_STATUS_SKIPPED;
191        $seconds = min($this->overallSecondsToSolve, time() - strtotime($currentAttempt['TimeModeAttempt']['started']));
192        $currentAttempt['TimeModeAttempt']['seconds'] = $seconds;
193        $currentAttempt['TimeModeAttempt']['points'] = 0;
194        ClassRegistry::init('TimeModeAttempt')->save($currentAttempt);
195        $this->currentOrder++;
196    }
197
198    // @return if not null, new tsumego id to show in the time mode
199    public function prepareNextToSolve(): ?int
200    {
201        if (!$this->currentSession)
202            return null;
203
204        $this->secondsToSolve = $this->overallSecondsToSolve;
205
206        // first one which doesn't have a status yet
207        $attempt = ClassRegistry::init('TimeModeAttempt')->find('first', [
208            'conditions' => [
209                'time_mode_session_id' => $this->currentSession['TimeModeSession']['id'],
210                'time_mode_attempt_status_id' => TimeModeUtil::$ATTEMPT_RESULT_QUEUED],
211            'order' => 'id ASC']);
212        if (!$attempt)
213            return null;
214        $attempt = $attempt['TimeModeAttempt'];
215
216        if (!$attempt['started'])
217        {
218            $attempt['started'] = date('Y-m-d H:i:s.u');
219            ClassRegistry::init('TimeModeAttempt')->save($attempt);
220        }
221        else
222        {
223            $start = new DateTime($attempt['started']);
224            $now = new DateTime();
225
226            $secondsSinceStarted = $now->getTimestamp() + $now->format('u') / 1e6 - ($start->getTimestamp() + $start->format('u') / 1e6);
227            $this->secondsToSolve = max(0, $this->secondsToSolve - $secondsSinceStarted);
228            if ($this->secondsToSolve == 0)
229            {
230                $attempt['time_mode_attempt_status_id'] = TimeModeUtil::$ATTEMPT_STATUS_TIMEOUT;
231                ClassRegistry::init('TimeModeAttempt')->save($attempt);
232                return $this->prepareNextToSolve();
233            }
234        }
235        return $attempt['tsumego_id'];
236    }
237
238    public function currentWillBeLast(): bool
239    {
240        return $this->currentOrder + 1 > $this->overallCount;
241    }
242
243    public function checkFinishSession(): ?int
244    {
245        if (!$this->currentSession)
246            return null;
247
248        if ($this->currentOrder - 1 < $this->overallCount)
249            return null;
250
251        $attempts = ClassRegistry::init('TimeModeAttempt')->find('all', ['conditions'
252        => ['time_mode_session_id' => $this->currentSession['TimeModeSession']['id']]]) ?: [];
253        assert(count($attempts) == $this->overallCount);
254
255        $overallPoints = 0.0;
256        $correctCount = 0;
257        foreach ($attempts as $attempt)
258        {
259            $overallPoints += $attempt['TimeModeAttempt']['points'];
260            if ($attempt['TimeModeAttempt']['time_mode_attempt_status_id'] == TimeModeUtil::$ATTEMPT_RESULT_SOLVED)
261                $correctCount++;
262        }
263
264        $sessionSuccessful = Util::getRatio($correctCount, count($attempts)) >= TimeModeUtil::$RATIO_OF_SOLVED_TO_SUCCEED;
265        $this->currentSession['TimeModeSession']['time_mode_session_status_id'] = $sessionSuccessful ? TimeModeUtil::$SESSION_STATUS_SOLVED : TimeModeUtil::$SESSION_STATUS_FAILED;
266        $this->currentSession['TimeModeSession']['points'] = $overallPoints;
267        ClassRegistry::init('TimeModeSession')->save($this->currentSession);
268        Auth::getUser()['mode'] = Constants::$LEVEL_MODE;
269        Auth::saveUser();
270
271        return $this->currentSession['TimeModeSession']['id'];
272    }
273
274    public static function calculatePoints(float $timeUsed, float $max): float
275    {
276        $timeRatio = 1 - ($timeUsed / $max);
277        return min(100 * (TimeModeUtil::$POINTS_RATIO_FOR_FINISHING + (1 - TimeModeUtil::$POINTS_RATIO_FOR_FINISHING) * $timeRatio), 100.0);
278    }
279
280    public function exportTimeModeInfo(): string
281    {
282        if (!$this->currentSession)
283            return 'null';
284        return 'new TimeModeState({'
285            . 'rank: \'' . $this->getCurrentRank() . '\','
286            . 'failCount: ' . $this->failCount . ','
287            . 'successCount: ' . $this->successCount . ','
288            . 'overallCount: ' . $this->overallCount . '})';
289    }
290
291    public function getCurrentRank(): string
292    {
293        if (!$this->rank)
294            return '';
295        return $this->rank['TimeModeRank']['name'];
296    }
297
298    public $currentSession;
299    public $rank;
300    public $secondsToSolve = 0; // remaining time
301    public $overallSecondsToSolve; // the time to solve the problem
302    public $successCount = 0;
303    public $failCount = 0;
304    public $overallCount = 0;
305    public $currentOrder;
306}